@mentra/bluetooth-sdk 0.1.19 → 0.1.20

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 (43) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +1 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +12 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +113 -147
  10. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
  12. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
  13. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +16 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  17. package/build/BluetoothSdk.types.d.ts +25 -0
  18. package/build/BluetoothSdk.types.d.ts.map +1 -1
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js.map +1 -1
  23. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  24. package/build/_private/photoRequestPayload.js +1 -0
  25. package/build/_private/photoRequestPayload.js.map +1 -1
  26. package/build/index.d.ts +1 -1
  27. package/build/index.d.ts.map +1 -1
  28. package/build/index.js +5 -0
  29. package/build/index.js.map +1 -1
  30. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  31. package/ios/Source/DeviceManager.swift +2 -1
  32. package/ios/Source/DeviceStore.swift +5 -0
  33. package/ios/Source/MentraBluetoothSDK.swift +14 -1
  34. package/ios/Source/camera/CameraModels.swift +17 -3
  35. package/ios/Source/sgcs/G1.swift +1 -1
  36. package/ios/Source/sgcs/MentraLive.swift +140 -17
  37. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  38. package/ios/Source/utils/G1Text.swift +30 -1
  39. package/package.json +1 -1
  40. package/src/BluetoothSdk.types.ts +26 -0
  41. package/src/_private/BluetoothSdkModule.ts +4 -0
  42. package/src/_private/photoRequestPayload.ts +1 -0
  43. package/src/index.ts +6 -0
@@ -104,6 +104,13 @@ class BlePhotoUploadService {
104
104
 
105
105
  private static func convertToJpegPreservingExif(imageData: Data) throws -> Data {
106
106
  logIncomingImageDiagnostics(imageData: imageData)
107
+ if isJpeg(imageData) {
108
+ Bridge.log(
109
+ "\(TAG): BLE relay pass-through: input already JPEG (\(imageData.count) bytes), skipping decode/re-encode"
110
+ )
111
+ return imageData
112
+ }
113
+
107
114
  let imuJson = readImuJsonFromImageData(imageData)
108
115
 
109
116
  guard let image = decodeImage(imageData: imageData) else {
@@ -118,10 +125,9 @@ class BlePhotoUploadService {
118
125
  "\(TAG): Decoded image to bitmap: \(Int(image.size.width))x\(Int(image.size.height))"
119
126
  )
120
127
 
121
- // 1.0: the source already went through a lossy AVIF pass on the glasses,
122
- // so this re-encode must not compound the loss. Phone CPU and upload
123
- // bandwidth are cheap relative to what was paid to get the bytes over BLE.
124
- guard var jpegData = image.jpegData(compressionQuality: 1.0) else {
128
+ // AVIF sources already went through a lossy pass on the glasses; re-encode
129
+ // at high-but-not-max quality. JPEG fast-path payloads upload as-is.
130
+ guard var jpegData = image.jpegData(compressionQuality: 0.9) else {
125
131
  throw NSError(
126
132
  domain: "BlePhotoUpload",
127
133
  code: -2,
@@ -148,6 +154,11 @@ class BlePhotoUploadService {
148
154
  )
149
155
  }
150
156
 
157
+ private static func isJpeg(_ data: Data) -> Bool {
158
+ let bytes = [UInt8](data.prefix(2))
159
+ return bytes.count >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8
160
+ }
161
+
151
162
  private static func describeContainer(_ data: Data) -> String {
152
163
  let bytes = [UInt8](data.prefix(12))
153
164
  if bytes.count >= 2, bytes[0] == 0xFF, bytes[1] == 0xD8 { return "jpeg" }
@@ -495,7 +506,7 @@ extension Data {
495
506
  }
496
507
  }
497
508
 
498
- private enum K900ProtocolUtils {
509
+ enum K900ProtocolUtils {
499
510
  // Protocol constants
500
511
  static let CMD_START_CODE: [UInt8] = [0x23, 0x23] // ##
501
512
  static let CMD_END_CODE: [UInt8] = [0x24, 0x24] // $$
@@ -515,7 +526,9 @@ private enum K900ProtocolUtils {
515
526
  static let CMD_TYPE_DATA: UInt8 = 0x35
516
527
 
517
528
  // File transfer constants
518
- static let FILE_PACK_SIZE = 400 // Max data size per packet
529
+ // Negotiated file protocol ceiling. Legacy/GATT transfers remain smaller; 800-byte frames are
530
+ // accepted only after file_payload_v2 negotiation and an open CoC channel.
531
+ static let FILE_PACK_SIZE = 800
519
532
  static let LENGTH_FILE_START = 2
520
533
  static let LENGTH_FILE_TYPE = 1
521
534
  static let LENGTH_FILE_PACKSIZE = 2
@@ -618,7 +631,7 @@ private enum K900ProtocolUtils {
618
631
  print(
619
632
  "K900ProtocolUtils: File packet checksum failed. Expected: \(String(format: "%02X", info.verifyCode)), Calculated: \(String(format: "%02X", calculatedVerify))"
620
633
  )
621
- } else {
634
+ } else if shouldLogFilePacket(info) {
622
635
  print(
623
636
  "K900ProtocolUtils: File packet extracted successfully: index=\(info.packIndex), size=\(info.packSize), fileName=\(info.fileName)"
624
637
  )
@@ -626,6 +639,14 @@ private enum K900ProtocolUtils {
626
639
 
627
640
  return info
628
641
  }
642
+
643
+ static func shouldLogFilePacket(_ info: FilePacketInfo) -> Bool {
644
+ let totalPackets =
645
+ (Int(info.fileSize) + FILE_PACK_SIZE - 1) / FILE_PACK_SIZE
646
+ return info.packIndex == 0
647
+ || info.packIndex % 32 == 0
648
+ || Int(info.packIndex) >= max(totalPackets - 1, 0)
649
+ }
629
650
  }
630
651
 
631
652
  private struct FileTransferSession {
@@ -921,6 +942,7 @@ extension MentraLive: CBCentralManagerDelegate {
921
942
  self.rgbLedAuthorityClaimed = false
922
943
 
923
944
  self.stopAllTimers()
945
+ self.closeL2capFileChannel()
924
946
 
925
947
  // Clean up characteristics
926
948
  self.txCharacteristic = nil
@@ -941,6 +963,7 @@ extension MentraLive: CBCentralManagerDelegate {
941
963
 
942
964
  self.stopConnectionTimeout()
943
965
  self.isConnecting = false
966
+ self.closeL2capFileChannel()
944
967
  self.connectedPeripheral = nil
945
968
  self.updateConnectionState(ConnTypes.DISCONNECTED)
946
969
 
@@ -1088,6 +1111,29 @@ extension MentraLive: CBPeripheralDelegate {
1088
1111
  }
1089
1112
  }
1090
1113
 
1114
+ nonisolated func peripheral(
1115
+ _ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: Error?
1116
+ ) {
1117
+ let errorDescription = error?.localizedDescription
1118
+ DispatchQueue.main.async { [weak self] in
1119
+ guard let self else { return }
1120
+ self.l2capFileChannelOpening = false
1121
+
1122
+ if let errorDescription {
1123
+ Bridge.log(
1124
+ "LIVE: L2CAP: unavailable, staying on GATT (\(errorDescription))"
1125
+ )
1126
+ return
1127
+ }
1128
+ guard peripheral === self.connectedPeripheral, let channel else {
1129
+ Bridge.log("LIVE: L2CAP: open completed for a stale connection")
1130
+ return
1131
+ }
1132
+
1133
+ self.attachL2capFileChannel(channel)
1134
+ }
1135
+ }
1136
+
1091
1137
  nonisolated func peripheral(
1092
1138
  _: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
1093
1139
  ) {
@@ -1227,6 +1273,7 @@ class MentraLive: NSObject, SGCManager {
1227
1273
  wireHandshakeQueued = false
1228
1274
  peerK900Le = false
1229
1275
  peerWireCapsBinary = false
1276
+ peerFilePayloadV2 = false
1230
1277
  BleJsonCompact.resetSession()
1231
1278
  stopSignalStrengthPolling()
1232
1279
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
@@ -1328,6 +1375,7 @@ class MentraLive: NSObject, SGCManager {
1328
1375
  private let TX_CHAR_UUID = CBUUID(string: "000071FF-0000-1000-8000-00805f9b34fb") // Central transmits on peripheral's RX
1329
1376
  private let FILE_READ_UUID = CBUUID(string: "000072FF-0000-1000-8000-00805f9b34fb")
1330
1377
  private let FILE_WRITE_UUID = CBUUID(string: "000073FF-0000-1000-8000-00805f9b34fb")
1378
+ private let L2CAP_FILE_PSM: CBL2CAPPSM = 0x00C9
1331
1379
 
1332
1380
  // LC3 Audio UUIDs (for K901+ devices with microphone support)
1333
1381
  private let LC3_READ_UUID = CBUUID(string: "6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
@@ -1341,6 +1389,9 @@ class MentraLive: NSObject, SGCManager {
1341
1389
  private var activeFileTransfers = [String: FileTransferSession]()
1342
1390
  private var blePhotoTransfers = [String: BlePhotoTransfer]()
1343
1391
  private var bleIncidentLogRelays = [String: BleIncidentLogRelayEntry]()
1392
+ private var l2capFileChannel: MentraLiveL2capChannel?
1393
+ private var l2capFileChannelId: UUID?
1394
+ private var l2capFileChannelOpening = false
1344
1395
  private var rgbLedAuthorityClaimed = false
1345
1396
 
1346
1397
  // LC3 Audio properties
@@ -1407,6 +1458,7 @@ class MentraLive: NSObject, SGCManager {
1407
1458
  // v2 binary handshake succeeds, which implies wire-v2 LE).
1408
1459
  private var peerK900Le = false
1409
1460
  private var peerWireCapsBinary = false
1461
+ private var peerFilePayloadV2 = false
1410
1462
  private var globalMessageId = 0
1411
1463
  private var lastReceivedMessageId = 0
1412
1464
  private var bleWriteTraceSequence = 0
@@ -1641,7 +1693,7 @@ class MentraLive: NSObject, SGCManager {
1641
1693
 
1642
1694
  func requestPhoto(_ request: PhotoRequest) {
1643
1695
  Bridge.log(
1644
- "LIVE: PHOTO PIPELINE [5/6] requestPhoto() entry requestId=\(request.requestId) save=\(request.save) sound=\(request.sound) iso=\(request.iso.map { String($0) } ?? "auto") aeDivisor=\(request.aeExposureDivisor.map { String($0) } ?? "nil")"
1696
+ "LIVE: PHOTO PIPELINE [5/6] requestPhoto() entry requestId=\(request.requestId) mode=\(request.mode.rawValue) save=\(request.save) sound=\(request.sound) iso=\(request.iso.map { String($0) } ?? "auto") aeDivisor=\(request.aeExposureDivisor.map { String($0) } ?? "nil")"
1645
1697
  )
1646
1698
 
1647
1699
  var json: [String: Any] = [
@@ -1678,6 +1730,7 @@ class MentraLive: NSObject, SGCManager {
1678
1730
  let allowedSizes = ["low", "medium", "high", "max"]
1679
1731
  let size = request.size.rawValue
1680
1732
  json["size"] = allowedSizes.contains(size) ? size : "medium"
1733
+ json["mode"] = request.mode.rawValue
1681
1734
 
1682
1735
  json["compress"] = request.compress?.rawValue ?? "none"
1683
1736
  json["save"] = request.save
@@ -2067,6 +2120,56 @@ class MentraLive: NSObject, SGCManager {
2067
2120
  )
2068
2121
  }
2069
2122
 
2123
+ // MARK: - L2CAP file channel
2124
+
2125
+ /// Open the BES file CoC. CoreBluetooth does not expose PHY or connection-priority
2126
+ /// controls, so BES owns those link updates when this channel is established.
2127
+ /// Any open failure leaves FILE_READ GATT notifications active as the fallback.
2128
+ private func openL2capFileChannel() {
2129
+ guard !l2capFileChannelOpening, l2capFileChannel == nil else { return }
2130
+ guard let peripheral = connectedPeripheral else { return }
2131
+
2132
+ l2capFileChannelOpening = true
2133
+ Bridge.log("LIVE: L2CAP: opening file channel (PSM 0xC9)")
2134
+ peripheral.openL2CAPChannel(L2CAP_FILE_PSM)
2135
+ }
2136
+
2137
+ private func attachL2capFileChannel(_ channel: CBL2CAPChannel) {
2138
+ closeL2capFileChannel()
2139
+
2140
+ let channelId = UUID()
2141
+ l2capFileChannelId = channelId
2142
+ let reader = MentraLiveL2capChannel(
2143
+ channel: channel,
2144
+ onFileFrame: { [weak self] frame in
2145
+ // The reader thread keeps draining the stream (and returning CoC credits)
2146
+ // while the existing transfer state remains serialized on the main actor.
2147
+ DispatchQueue.main.async {
2148
+ self?.processReceivedData(frame)
2149
+ }
2150
+ },
2151
+ onClose: { [weak self] in
2152
+ DispatchQueue.main.async {
2153
+ guard let self, self.l2capFileChannelId == channelId else { return }
2154
+ self.l2capFileChannel = nil
2155
+ self.l2capFileChannelId = nil
2156
+ Bridge.log("LIVE: L2CAP: channel closed; using GATT fallback")
2157
+ }
2158
+ }
2159
+ )
2160
+ l2capFileChannel = reader
2161
+ reader.start()
2162
+ Bridge.log("LIVE: L2CAP: channel open (PSM 0xC9)")
2163
+ }
2164
+
2165
+ private func closeL2capFileChannel() {
2166
+ l2capFileChannelOpening = false
2167
+ l2capFileChannelId = nil
2168
+ let reader = l2capFileChannel
2169
+ l2capFileChannel = nil
2170
+ reader?.close()
2171
+ }
2172
+
2070
2173
  // MARK: - Data Processing
2071
2174
 
2072
2175
  private func processReceivedData(_ data: Data) {
@@ -2105,10 +2208,6 @@ class MentraLive: NSObject, SGCManager {
2105
2208
  || commandType == K900ProtocolUtils.CMD_TYPE_AUDIO
2106
2209
  || commandType == K900ProtocolUtils.CMD_TYPE_DATA
2107
2210
  {
2108
- Bridge.log(
2109
- "📦 DETECTED FILE TRANSFER PACKET (type: 0x\(String(format: "%02X", commandType)))"
2110
- )
2111
-
2112
2211
  // Debug: Log the raw data
2113
2212
  // let hexDump = data.prefix(64).map { String(format: "%02X ", $0) }.joined()
2114
2213
  // Bridge.log("📦 Raw file packet data length=\(data.count), first 64 bytes: \(hexDump)")
@@ -3020,6 +3119,8 @@ class MentraLive: NSObject, SGCManager {
3020
3119
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
3021
3120
 
3022
3121
  stopReadinessCheckLoop()
3122
+ advertiseFilePayloadCapabilityToBes()
3123
+ openL2capFileChannel()
3023
3124
  sendBleMtuConfig()
3024
3125
 
3025
3126
  // Invalidate any version fields from a prior link session so the next version_info
@@ -3336,7 +3437,9 @@ class MentraLive: NSObject, SGCManager {
3336
3437
  }
3337
3438
 
3338
3439
  if let incidentRelay = bleIncidentLogRelays[bleImgId] {
3339
- Bridge.log("LIVE: 📦 BLE incident log relay packet for: \(bleImgId)")
3440
+ if K900ProtocolUtils.shouldLogFilePacket(packetInfo) {
3441
+ Bridge.log("LIVE: 📦 BLE incident log relay packet for: \(bleImgId)")
3442
+ }
3340
3443
 
3341
3444
  if incidentRelay.session == nil {
3342
3445
  activeFileTransfers.removeValue(forKey: packetInfo.fileName)
@@ -3386,7 +3489,9 @@ class MentraLive: NSObject, SGCManager {
3386
3489
 
3387
3490
  if var photoTransfer = blePhotoTransfers[bleImgId] {
3388
3491
  // This is a BLE photo transfer
3389
- Bridge.log("📦 BLE photo transfer packet for requestId: \(photoTransfer.requestId)")
3492
+ if K900ProtocolUtils.shouldLogFilePacket(packetInfo) {
3493
+ Bridge.log("📦 BLE photo transfer packet for requestId: \(photoTransfer.requestId)")
3494
+ }
3390
3495
 
3391
3496
  // Get or create session for this transfer
3392
3497
  if photoTransfer.session == nil {
@@ -3482,9 +3587,11 @@ class MentraLive: NSObject, SGCManager {
3482
3587
  activeFileTransfers[packetInfo.fileName] = sess
3483
3588
 
3484
3589
  if added {
3485
- Bridge.log(
3486
- "LIVE: 📦 Packet \(packetInfo.packIndex) received successfully (BES will auto-ACK)"
3487
- )
3590
+ if K900ProtocolUtils.shouldLogFilePacket(packetInfo) {
3591
+ Bridge.log(
3592
+ "LIVE: 📦 Packet \(packetInfo.packIndex) received successfully (BES will auto-ACK)"
3593
+ )
3594
+ }
3488
3595
 
3489
3596
  if sess.isComplete {
3490
3597
  Bridge.log("LIVE: 📦 File transfer complete: \(packetInfo.fileName)")
@@ -4905,7 +5012,9 @@ class MentraLive: NSObject, SGCManager {
4905
5012
  wireHandshakeQueued = false
4906
5013
  peerK900Le = false
4907
5014
  peerWireCapsBinary = false
5015
+ peerFilePayloadV2 = false
4908
5016
  BleJsonCompact.resetSession()
5017
+ closeL2capFileChannel()
4909
5018
 
4910
5019
  // Disconnect BLE
4911
5020
  if let peripheral = connectedPeripheral {
@@ -5008,6 +5117,20 @@ extension MentraLive {
5008
5117
  if caps.keys.contains("binary") {
5009
5118
  peerWireCapsBinary = (caps["binary"] as? Bool) == true
5010
5119
  }
5120
+ if caps.keys.contains("file_payload_v2") {
5121
+ peerFilePayloadV2 = (caps["file_payload_v2"] as? Bool) == true
5122
+ }
5123
+ }
5124
+
5125
+ private func advertiseFilePayloadCapabilityToBes() {
5126
+ guard peerFilePayloadV2 else { return }
5127
+ let body = "{\"max\":\(K900ProtocolUtils.FILE_PACK_SIZE)}"
5128
+ let command: [String: Any] = ["C": "cs_file_payload", "B": body]
5129
+ if sendRawK900Command(command) {
5130
+ Bridge.log(
5131
+ "LIVE: 📦 Advertised file payload ceiling \(K900ProtocolUtils.FILE_PACK_SIZE) to BES"
5132
+ )
5133
+ }
5011
5134
  }
5012
5135
 
5013
5136
  /**
@@ -0,0 +1,183 @@
1
+ import CoreBluetooth
2
+ import Foundation
3
+
4
+ /// Read-only LE L2CAP CoC fast path for Mentra Live file transfers.
5
+ ///
6
+ /// BES sends the same K900 file frames over this channel that it otherwise
7
+ /// sends through the FILE_READ GATT characteristic. The stream is drained on
8
+ /// a dedicated thread so CoreBluetooth credits are returned independently of
9
+ /// React Native and main-thread work.
10
+ final class MentraLiveL2capChannel: NSObject, StreamDelegate {
11
+ private static let readChunkSize = 4096
12
+ private static let maxBufferSize = 64 * 1024
13
+ private static let frameHeaderSize = 5
14
+ private static let frameOverhead = 32
15
+ private static let maxFilePayloadSize = K900ProtocolUtils.FILE_PACK_SIZE
16
+
17
+ private let channel: CBL2CAPChannel
18
+ private let onFileFrame: (Data) -> Void
19
+ private let onClose: () -> Void
20
+ private let stateLock = NSLock()
21
+
22
+ private var receiveBuffer = [UInt8]()
23
+ private var worker: Thread?
24
+ private var closed = false
25
+ private var closeNotified = false
26
+
27
+ init(
28
+ channel: CBL2CAPChannel,
29
+ onFileFrame: @escaping (Data) -> Void,
30
+ onClose: @escaping () -> Void
31
+ ) {
32
+ self.channel = channel
33
+ self.onFileFrame = onFileFrame
34
+ self.onClose = onClose
35
+ super.init()
36
+ }
37
+
38
+ func start() {
39
+ guard worker == nil else { return }
40
+ let thread = Thread { [weak self] in
41
+ self?.runReadLoop()
42
+ }
43
+ thread.name = "MentraLive-L2CAP"
44
+ thread.qualityOfService = .userInitiated
45
+ worker = thread
46
+ thread.start()
47
+ }
48
+
49
+ func close() {
50
+ stateLock.lock()
51
+ let wasClosed = closed
52
+ closed = true
53
+ stateLock.unlock()
54
+
55
+ guard !wasClosed else { return }
56
+ channel.inputStream?.close()
57
+ channel.outputStream?.close()
58
+ }
59
+
60
+ private var isClosed: Bool {
61
+ stateLock.lock()
62
+ defer { stateLock.unlock() }
63
+ return closed
64
+ }
65
+
66
+ private func runReadLoop() {
67
+ guard let input = channel.inputStream, let output = channel.outputStream else {
68
+ notifyClosed()
69
+ return
70
+ }
71
+
72
+ input.delegate = self
73
+ input.schedule(in: .current, forMode: .default)
74
+ output.schedule(in: .current, forMode: .default)
75
+ input.open()
76
+ output.open()
77
+
78
+ while !isClosed {
79
+ _ = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1))
80
+ }
81
+
82
+ input.close()
83
+ output.close()
84
+ input.remove(from: .current, forMode: .default)
85
+ output.remove(from: .current, forMode: .default)
86
+ input.delegate = nil
87
+ notifyClosed()
88
+ }
89
+
90
+ func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
91
+ guard aStream === channel.inputStream else { return }
92
+
93
+ switch eventCode {
94
+ case .hasBytesAvailable:
95
+ drainInput()
96
+ case .endEncountered, .errorOccurred:
97
+ close()
98
+ default:
99
+ break
100
+ }
101
+ }
102
+
103
+ private func drainInput() {
104
+ guard let input = channel.inputStream else { return }
105
+ var chunk = [UInt8](repeating: 0, count: Self.readChunkSize)
106
+
107
+ while input.hasBytesAvailable, !isClosed {
108
+ let count = input.read(&chunk, maxLength: chunk.count)
109
+ if count > 0 {
110
+ appendAndDispatch(Array(chunk.prefix(count)))
111
+ } else if count < 0 {
112
+ close()
113
+ return
114
+ } else {
115
+ break
116
+ }
117
+ }
118
+ }
119
+
120
+ private func appendAndDispatch(_ bytes: [UInt8]) {
121
+ if receiveBuffer.count + bytes.count > Self.maxBufferSize {
122
+ receiveBuffer.removeAll(keepingCapacity: true)
123
+ }
124
+ guard bytes.count <= Self.maxBufferSize else { return }
125
+ receiveBuffer.append(contentsOf: bytes)
126
+
127
+ while true {
128
+ guard let start = startMarkerIndex() else {
129
+ if receiveBuffer.last == 0x23 {
130
+ receiveBuffer = [0x23]
131
+ } else {
132
+ receiveBuffer.removeAll(keepingCapacity: true)
133
+ }
134
+ return
135
+ }
136
+
137
+ if start > 0 {
138
+ receiveBuffer.removeFirst(start)
139
+ }
140
+ guard receiveBuffer.count >= Self.frameHeaderSize else { return }
141
+
142
+ let payloadSize = (Int(receiveBuffer[3]) << 8) | Int(receiveBuffer[4])
143
+ guard payloadSize <= Self.maxFilePayloadSize else {
144
+ receiveBuffer.removeFirst()
145
+ continue
146
+ }
147
+
148
+ let frameSize = Self.frameOverhead + payloadSize
149
+ guard receiveBuffer.count >= frameSize else { return }
150
+
151
+ guard receiveBuffer[frameSize - 2] == 0x24,
152
+ receiveBuffer[frameSize - 1] == 0x24
153
+ else {
154
+ receiveBuffer.removeFirst()
155
+ continue
156
+ }
157
+
158
+ onFileFrame(Data(receiveBuffer.prefix(frameSize)))
159
+ receiveBuffer.removeFirst(frameSize)
160
+ }
161
+ }
162
+
163
+ private func startMarkerIndex() -> Int? {
164
+ guard receiveBuffer.count >= 2 else { return nil }
165
+ for index in 0 ..< (receiveBuffer.count - 1)
166
+ where receiveBuffer[index] == 0x23 && receiveBuffer[index + 1] == 0x23
167
+ {
168
+ return index
169
+ }
170
+ return nil
171
+ }
172
+
173
+ private func notifyClosed() {
174
+ stateLock.lock()
175
+ guard !closeNotified else {
176
+ stateLock.unlock()
177
+ return
178
+ }
179
+ closeNotified = true
180
+ stateLock.unlock()
181
+ onClose()
182
+ }
183
+ }
@@ -24,6 +24,33 @@ class G1Text {
24
24
 
25
25
  init() {}
26
26
 
27
+ /// Converts text to the base Latin glyphs available in G1 firmware.
28
+ /// Newer glasses bypass this G1-only transport and keep the original text.
29
+ static func sanitizeForDisplay(_ text: String) -> String {
30
+ let expanded = text.reduce(into: "") { result, character in
31
+ switch character {
32
+ case "Đ", "Ð": result.append("D")
33
+ case "đ", "ð": result.append("d")
34
+ case "Ł": result.append("L")
35
+ case "ł": result.append("l")
36
+ case "Ø": result.append("O")
37
+ case "ø": result.append("o")
38
+ case "Æ": result.append("AE")
39
+ case "æ": result.append("ae")
40
+ case "Œ": result.append("OE")
41
+ case "œ": result.append("oe")
42
+ case "ẞ": result.append("SS")
43
+ case "ß": result.append("ss")
44
+ case "Þ": result.append("TH")
45
+ case "þ": result.append("th")
46
+ default: result.append(character)
47
+ }
48
+ }
49
+
50
+ return expanded
51
+ .folding(options: .diacriticInsensitive, locale: Locale(identifier: "en_US_POSIX"))
52
+ }
53
+
27
54
  // MARK: - Text Wall Methods
28
55
 
29
56
  // func displayTextWall(_ text: String) {
@@ -72,7 +99,9 @@ class G1Text {
72
99
  func chunkTextForTransmission(_ text: String) -> [[UInt8]] {
73
100
  // Handle empty or whitespace-only text by sending at least a space
74
101
  // This ensures the display gets updated/cleared properly
75
- let textToSend = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? " " : text
102
+ let textToSend = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
103
+ ? " "
104
+ : G1Text.sanitizeForDisplay(text)
76
105
  guard let textData = textToSend.data(using: .utf8) else { return [] }
77
106
  let textBytes = [UInt8](textData)
78
107
  let totalChunks = Int(ceil(Double(textBytes.count) / Double(G1Text.MAX_CHUNK_SIZE)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -440,8 +440,14 @@ export type SettingsAckSuccessEvent = Omit<SettingsAckEvent, "status"> & {
440
440
  export type RgbLedAction = "on" | "off"
441
441
  export type RgbLedColor = "red" | "green" | "blue" | "orange" | "white"
442
442
  export type PhotoSize = "low" | "medium" | "high" | "max"
443
+ export type PhotoMode = "photo" | "text"
443
444
  export type ButtonPhotoSize = "low" | "medium" | "high" | "max"
444
445
 
446
+ /**
447
+ * @deprecated Sticky action-button photo presets via {@link BluetoothSdkPublicModule.setPhotoCaptureDefaults}
448
+ * are deprecated. Prefer per-request {@link BluetoothSdkPublicModule.requestPhoto} options
449
+ * (e.g. `mode: "text"` for AE ÷3) instead of persisting button-photo tuning on the glasses.
450
+ */
445
451
  export type PhotoCaptureDefaults = {
446
452
  size?: PhotoSize
447
453
  mfnr?: boolean
@@ -544,6 +550,7 @@ export type PhotoRequestParams = {
544
550
  requestId?: string
545
551
  appId?: string
546
552
  size: PhotoSize
553
+ mode?: PhotoMode
547
554
  webhookUrl: string | null
548
555
  authToken: string | null
549
556
  compress: PhotoCompression
@@ -690,11 +697,25 @@ export type StreamResolvedConfig = {
690
697
  }
691
698
  }
692
699
 
700
+ /** Live encoder telemetry, when the glasses emit it (no firmware does yet). */
701
+ export type StreamLiveStats = {
702
+ /** Current encoded video bitrate in bits per second. */
703
+ bitrate?: number
704
+ /** Current encode frame rate. */
705
+ fps?: number
706
+ droppedFrames?: number
707
+ /** Seconds since the stream started. */
708
+ duration?: number
709
+ /** Device temperature in °C, if the hardware reports it. */
710
+ temperatureC?: number
711
+ }
712
+
693
713
  type StreamStatusCommon = {
694
714
  type: "stream_status"
695
715
  streamId?: string
696
716
  timestamp?: number
697
717
  resolvedConfig?: StreamResolvedConfig
718
+ stats?: StreamLiveStats
698
719
  }
699
720
 
700
721
  export type StreamStatusEvent =
@@ -972,6 +993,11 @@ export interface BluetoothSdkPublicModule {
972
993
 
973
994
  setGalleryModeEnabled(enabled: boolean): Promise<SettingsAckSuccessEvent>
974
995
  setVoiceActivityDetectionEnabled(enabled: boolean): Promise<void>
996
+ /**
997
+ * @deprecated Sticky action-button photo presets are deprecated. Prefer per-request
998
+ * `requestPhoto(...)` options (e.g. `mode: "text"` for AE ÷3, or explicit per-shot
999
+ * fields). Still functional until removed in a future release.
1000
+ */
975
1001
  setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): Promise<SettingsAckSuccessEvent>
976
1002
  setVideoRecordingDefaults(settings: VideoRecordingDefaults): Promise<SettingsAckSuccessEvent>
977
1003
  setMaxVideoRecordingDuration(minutes: number): Promise<SettingsAckSuccessEvent>
@@ -124,6 +124,10 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
124
124
  // Gallery Commands
125
125
  setGalleryModeEnabled(enabled: boolean): Promise<SettingsAckSuccessEvent>
126
126
  setVoiceActivityDetectionEnabled(enabled: boolean): Promise<void>
127
+ /**
128
+ * @deprecated Sticky action-button photo presets are deprecated. Prefer per-request
129
+ * `requestPhoto(...)` options (e.g. `mode: "text"` for AE ÷3).
130
+ */
127
131
  setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): Promise<SettingsAckSuccessEvent>
128
132
  setVideoRecordingDefaults(width: number, height: number, fps: number): Promise<SettingsAckSuccessEvent>
129
133
  setMaxVideoRecordingDuration(minutes: number): Promise<SettingsAckSuccessEvent>
@@ -30,6 +30,7 @@ export function photoRequestParamsForNative(
30
30
  ): Record<string, string | number | boolean> {
31
31
  const payload: Record<string, string | number | boolean> = {
32
32
  size: normalizePhotoSizeTier(params.size),
33
+ mode: params.mode ?? "photo",
33
34
  webhookUrl: params.webhookUrl ?? "",
34
35
  compress: params.compress,
35
36
  sound: params.sound,
package/src/index.ts CHANGED
@@ -96,6 +96,11 @@ export const BluetoothSdk: BluetoothSdkPublicModule = Object.freeze({
96
96
  setHotspotState: bindPublicMethod("setHotspotState"),
97
97
  setGalleryModeEnabled: bindPublicMethod("setGalleryModeEnabled"),
98
98
  setVoiceActivityDetectionEnabled: bindPublicMethod("setVoiceActivityDetectionEnabled"),
99
+ /**
100
+ * @deprecated Sticky action-button photo presets are deprecated. Prefer per-request
101
+ * `requestPhoto(...)` options (e.g. `mode: "text"` for AE ÷3, or explicit per-shot
102
+ * fields). Still functional until removed in a future release.
103
+ */
99
104
  setPhotoCaptureDefaults: bindPublicMethod("setPhotoCaptureDefaults"),
100
105
  setVideoRecordingDefaults: ({width, height, fps}: VideoRecordingDefaults) => {
101
106
  const method = (PrivateBluetoothSdkModule as unknown as Record<string, unknown>).setVideoRecordingDefaults
@@ -204,6 +209,7 @@ export type {
204
209
  PhotoCompression,
205
210
  PhotoFpsRange,
206
211
  PhotoMeteredPreview,
212
+ PhotoMode,
207
213
  PhotoResponseEvent,
208
214
  PhotoRequestedCaptureConfig,
209
215
  PhotoRequestParams,