@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.
- package/README.md +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +113 -147
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +16 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
- package/build/BluetoothSdk.types.d.ts +25 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +1 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -0
- package/build/index.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +2 -1
- package/ios/Source/DeviceStore.swift +5 -0
- package/ios/Source/MentraBluetoothSDK.swift +14 -1
- package/ios/Source/camera/CameraModels.swift +17 -3
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +140 -17
- package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
- package/ios/Source/utils/G1Text.swift +30 -1
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +26 -0
- package/src/_private/BluetoothSdkModule.ts +4 -0
- package/src/_private/photoRequestPayload.ts +1 -0
- 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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3486
|
-
|
|
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
|
|
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
|
@@ -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,
|