@mentra/bluetooth-sdk 0.1.10 → 0.1.12
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 +52 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +61 -19
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +32 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +76 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +829 -643
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +34 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +40 -64
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +23 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
- package/build/BluetoothSdk.types.d.ts +45 -2
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -1
- 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 +4 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +24 -7
- package/ios/Source/BluetoothSdkDefaults.swift +27 -0
- package/ios/Source/Bridge.swift +16 -0
- package/ios/Source/DeviceManager.swift +33 -27
- package/ios/Source/DeviceStore.swift +8 -2
- package/ios/Source/MentraBluetoothSDK.swift +160 -8
- package/ios/Source/camera/CameraModels.swift +50 -1
- package/ios/Source/events/BluetoothEvents.swift +3 -0
- package/ios/Source/internal/BluetoothAvailability.swift +20 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -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 +31 -7
- package/ios/Source/sgcs/MentraNex.swift +215 -85
- package/ios/Source/sgcs/SGCManager.swift +26 -6
- package/ios/Source/sgcs/Simulated.swift +2 -2
- package/ios/Source/types/DeviceModels.swift +5 -1
- package/package.json +1 -1
- package/plugin/build/index.d.ts +4 -0
- package/plugin/build/withAndroid.d.ts +2 -3
- package/plugin/build/withAndroid.js +46 -0
- package/plugin/build/withIos.d.ts +2 -3
- package/plugin/build/withIos.js +32 -0
- package/src/BluetoothSdk.types.ts +53 -2
- package/src/_private/BluetoothSdkModule.ts +27 -4
- package/src/index.ts +5 -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()
|
|
@@ -2171,6 +2171,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2171
2171
|
case "video_recording_status":
|
|
2172
2172
|
emitVideoRecordingStatus(json)
|
|
2173
2173
|
|
|
2174
|
+
case "media_success", "media_error":
|
|
2175
|
+
Bridge.sendMediaUploadEvent(type: type, values: json)
|
|
2176
|
+
|
|
2174
2177
|
case "photo_status":
|
|
2175
2178
|
emitPhotoStatus(json)
|
|
2176
2179
|
|
|
@@ -5269,7 +5272,8 @@ extension MentraLive {
|
|
|
5269
5272
|
|
|
5270
5273
|
func startVideoRecording(requestId: String, save: Bool, flash: Bool, sound: Bool) {
|
|
5271
5274
|
startVideoRecording(
|
|
5272
|
-
requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0
|
|
5275
|
+
requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0,
|
|
5276
|
+
maxRecordingTimeMinutes: 0
|
|
5273
5277
|
)
|
|
5274
5278
|
}
|
|
5275
5279
|
|
|
@@ -5280,10 +5284,11 @@ extension MentraLive {
|
|
|
5280
5284
|
}
|
|
5281
5285
|
|
|
5282
5286
|
func startVideoRecording(
|
|
5283
|
-
requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int
|
|
5287
|
+
requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int,
|
|
5288
|
+
maxRecordingTimeMinutes: Int
|
|
5284
5289
|
) {
|
|
5285
5290
|
Bridge.log(
|
|
5286
|
-
"Starting video recording on glasses: requestId=\(requestId), save=\(save), flash=\(flash), sound=\(sound), resolution=\(width)x\(height)@\(fps)fps"
|
|
5291
|
+
"Starting video recording on glasses: requestId=\(requestId), save=\(save), flash=\(flash), sound=\(sound), resolution=\(width)x\(height)@\(fps)fps, maxRecordingTimeMinutes=\(maxRecordingTimeMinutes)"
|
|
5287
5292
|
)
|
|
5288
5293
|
|
|
5289
5294
|
guard connectionState == ConnTypes.CONNECTED else {
|
|
@@ -5299,6 +5304,11 @@ extension MentraLive {
|
|
|
5299
5304
|
"sound": sound,
|
|
5300
5305
|
]
|
|
5301
5306
|
|
|
5307
|
+
// Auto-stop timer; only sent when set (> 0). 0 = record until stopped.
|
|
5308
|
+
if maxRecordingTimeMinutes > 0 {
|
|
5309
|
+
json["maxRecordingTimeMinutes"] = maxRecordingTimeMinutes
|
|
5310
|
+
}
|
|
5311
|
+
|
|
5302
5312
|
// Add video settings when any field is overridden. Each field is sent
|
|
5303
5313
|
// only when > 0; the glasses merge the missing fields onto their saved
|
|
5304
5314
|
// button-video defaults, so a partial override (e.g. fps-only) still
|
|
@@ -5314,17 +5324,31 @@ extension MentraLive {
|
|
|
5314
5324
|
}
|
|
5315
5325
|
|
|
5316
5326
|
func stopVideoRecording(requestId: String) {
|
|
5317
|
-
|
|
5327
|
+
stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
|
|
5328
|
+
}
|
|
5329
|
+
|
|
5330
|
+
func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
5331
|
+
Bridge.log(
|
|
5332
|
+
"Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
|
|
5333
|
+
)
|
|
5318
5334
|
|
|
5319
5335
|
guard connectionState == ConnTypes.CONNECTED else {
|
|
5320
5336
|
Bridge.log("Cannot stop video recording - not connected")
|
|
5321
5337
|
return
|
|
5322
5338
|
}
|
|
5323
5339
|
|
|
5324
|
-
|
|
5340
|
+
var json: [String: Any] = [
|
|
5325
5341
|
"type": "stop_video_recording",
|
|
5326
5342
|
"requestId": requestId,
|
|
5327
5343
|
]
|
|
5344
|
+
// Webhook upload target, supplied at stop so the token is fresh.
|
|
5345
|
+
// Only sent when present; empty webhook = keep video on device.
|
|
5346
|
+
if let webhookUrl, !webhookUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
5347
|
+
json["webhookUrl"] = webhookUrl
|
|
5348
|
+
}
|
|
5349
|
+
if let authToken, !authToken.isEmpty {
|
|
5350
|
+
json["authToken"] = authToken
|
|
5351
|
+
}
|
|
5328
5352
|
sendJson(json)
|
|
5329
5353
|
}
|
|
5330
5354
|
}
|
|
@@ -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)
|