@mentra/bluetooth-sdk 0.1.19 → 0.1.21-beta.0
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 +6 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +239 -52
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +47 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +6 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +381 -97
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +16 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +293 -174
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- 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/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +31 -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 +71 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +27 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +20 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +1 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +2 -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 +9 -0
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +72 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +72 -18
- package/ios/Source/DeviceManager.swift +52 -3
- package/ios/Source/DeviceStore.swift +6 -1
- package/ios/Source/MentraBluetoothSDK.swift +298 -21
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +22 -3
- package/ios/Source/sgcs/G1.swift +2 -2
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +456 -52
- package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +5 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +9 -0
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/ios/Source/utils/G1Text.swift +30 -1
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +75 -0
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +68 -13
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +1 -0
- package/src/_private/photoRequestPayload.ts +3 -3
- package/src/index.ts +10 -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 {
|
|
@@ -905,6 +926,18 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
905
926
|
}
|
|
906
927
|
}
|
|
907
928
|
|
|
929
|
+
nonisolated func centralManager(
|
|
930
|
+
_: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral
|
|
931
|
+
) {
|
|
932
|
+
DispatchQueue.main.async { [weak self] in
|
|
933
|
+
guard let self, peripheral === self.connectedPeripheral else { return }
|
|
934
|
+
Bridge.log(
|
|
935
|
+
"LIVE: ANCS authorization updated: \(peripheral.ancsAuthorized ? "authorized" : "not authorized")"
|
|
936
|
+
)
|
|
937
|
+
self.enableAncsRelayIfAuthorized()
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
908
941
|
nonisolated func centralManager(
|
|
909
942
|
_: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?
|
|
910
943
|
) {
|
|
@@ -921,6 +954,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
921
954
|
self.rgbLedAuthorityClaimed = false
|
|
922
955
|
|
|
923
956
|
self.stopAllTimers()
|
|
957
|
+
self.closeL2capFileChannel()
|
|
924
958
|
|
|
925
959
|
// Clean up characteristics
|
|
926
960
|
self.txCharacteristic = nil
|
|
@@ -941,6 +975,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
941
975
|
|
|
942
976
|
self.stopConnectionTimeout()
|
|
943
977
|
self.isConnecting = false
|
|
978
|
+
self.closeL2capFileChannel()
|
|
944
979
|
self.connectedPeripheral = nil
|
|
945
980
|
self.updateConnectionState(ConnTypes.DISCONNECTED)
|
|
946
981
|
|
|
@@ -1072,6 +1107,10 @@ extension MentraLive: CBPeripheralDelegate {
|
|
|
1072
1107
|
Bridge.log("LIVE: 🎤 Enabled LC3 audio notifications")
|
|
1073
1108
|
}
|
|
1074
1109
|
|
|
1110
|
+
// Authorization may complete before or after service discovery.
|
|
1111
|
+
// This covers the former; the central-manager callback covers the latter.
|
|
1112
|
+
self.enableAncsRelayIfAuthorized()
|
|
1113
|
+
|
|
1075
1114
|
// Start readiness check loop
|
|
1076
1115
|
self.startSignalStrengthPolling()
|
|
1077
1116
|
self.startReadinessCheckLoop()
|
|
@@ -1088,6 +1127,29 @@ extension MentraLive: CBPeripheralDelegate {
|
|
|
1088
1127
|
}
|
|
1089
1128
|
}
|
|
1090
1129
|
|
|
1130
|
+
nonisolated func peripheral(
|
|
1131
|
+
_ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: Error?
|
|
1132
|
+
) {
|
|
1133
|
+
let errorDescription = error?.localizedDescription
|
|
1134
|
+
DispatchQueue.main.async { [weak self] in
|
|
1135
|
+
guard let self else { return }
|
|
1136
|
+
self.l2capFileChannelOpening = false
|
|
1137
|
+
|
|
1138
|
+
if let errorDescription {
|
|
1139
|
+
Bridge.log(
|
|
1140
|
+
"LIVE: L2CAP: unavailable, staying on GATT (\(errorDescription))"
|
|
1141
|
+
)
|
|
1142
|
+
return
|
|
1143
|
+
}
|
|
1144
|
+
guard peripheral === self.connectedPeripheral, let channel else {
|
|
1145
|
+
Bridge.log("LIVE: L2CAP: open completed for a stale connection")
|
|
1146
|
+
return
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
self.attachL2capFileChannel(channel)
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1091
1153
|
nonisolated func peripheral(
|
|
1092
1154
|
_: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
|
|
1093
1155
|
) {
|
|
@@ -1167,6 +1229,7 @@ extension MentraLive: CBPeripheralDelegate {
|
|
|
1167
1229
|
|
|
1168
1230
|
if uuid == self.RX_CHAR_UUID, isNotifying {
|
|
1169
1231
|
Bridge.log("LIVE: 🔔 Ready to receive data via notifications")
|
|
1232
|
+
self.enableAncsRelayIfAuthorized()
|
|
1170
1233
|
}
|
|
1171
1234
|
}
|
|
1172
1235
|
}
|
|
@@ -1221,13 +1284,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1221
1284
|
// a previous pairing into the next one (would otherwise surface as wrong overall_percent
|
|
1222
1285
|
// or stale lastBesOtaProgress on the next OTA).
|
|
1223
1286
|
if state == ConnTypes.DISCONNECTED {
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
BleJsonCompact.resetSession()
|
|
1287
|
+
resetWireNegotiationState()
|
|
1288
|
+
resetAncsRelayState()
|
|
1289
|
+
// Queued writes are session-bound: transmitting them into the NEXT session
|
|
1290
|
+
// (e.g. a stale handshake whose reply activates v2 before the new session
|
|
1291
|
+
// negotiated) is the bug class this clear removes.
|
|
1292
|
+
Task { await commandQueue.removeAll() }
|
|
1231
1293
|
stopSignalStrengthPolling()
|
|
1232
1294
|
DeviceStore.shared.apply("glasses", "signalStrength", -1)
|
|
1233
1295
|
DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
|
|
@@ -1328,6 +1390,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1328
1390
|
private let TX_CHAR_UUID = CBUUID(string: "000071FF-0000-1000-8000-00805f9b34fb") // Central transmits on peripheral's RX
|
|
1329
1391
|
private let FILE_READ_UUID = CBUUID(string: "000072FF-0000-1000-8000-00805f9b34fb")
|
|
1330
1392
|
private let FILE_WRITE_UUID = CBUUID(string: "000073FF-0000-1000-8000-00805f9b34fb")
|
|
1393
|
+
private let L2CAP_FILE_PSM: CBL2CAPPSM = 0x00C9
|
|
1331
1394
|
|
|
1332
1395
|
// LC3 Audio UUIDs (for K901+ devices with microphone support)
|
|
1333
1396
|
private let LC3_READ_UUID = CBUUID(string: "6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
|
|
@@ -1341,6 +1404,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1341
1404
|
private var activeFileTransfers = [String: FileTransferSession]()
|
|
1342
1405
|
private var blePhotoTransfers = [String: BlePhotoTransfer]()
|
|
1343
1406
|
private var bleIncidentLogRelays = [String: BleIncidentLogRelayEntry]()
|
|
1407
|
+
private var l2capFileChannel: MentraLiveL2capChannel?
|
|
1408
|
+
private var l2capFileChannelId: UUID?
|
|
1409
|
+
private var l2capFileChannelOpening = false
|
|
1344
1410
|
private var rgbLedAuthorityClaimed = false
|
|
1345
1411
|
|
|
1346
1412
|
// LC3 Audio properties
|
|
@@ -1359,6 +1425,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1359
1425
|
// So we temporarily suspend the LC3 mic during phone audio playback
|
|
1360
1426
|
private var micIntentEnabled = false // User/system WANTS mic enabled
|
|
1361
1427
|
private var micSuspendedForAudio = false // Mic temporarily suspended due to phone audio
|
|
1428
|
+
var isMicSuspendedForAudio: Bool { micSuspendedForAudio }
|
|
1362
1429
|
private var phoneAudioMonitor: PhoneAudioMonitor?
|
|
1363
1430
|
|
|
1364
1431
|
// Timing Constants
|
|
@@ -1402,11 +1469,26 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1402
1469
|
private var peerWireProtocolVersion = 0
|
|
1403
1470
|
private var useBinaryWireProtocol = false
|
|
1404
1471
|
private var wireHandshakeQueued = false
|
|
1472
|
+
private var wireHandshakeAttempts = 0
|
|
1473
|
+
// Session generation in which the LAST outgoing v2 handshake was sent; a reply only
|
|
1474
|
+
// activates v2 when it answers a handshake from the CURRENT epoch (see PendingMessage
|
|
1475
|
+
// .generation for the queue-side guard).
|
|
1476
|
+
private var wireHandshakeSentGeneration = -1
|
|
1477
|
+
// Bumped on every BLE session reset; scheduled handshake-retry callbacks capture it
|
|
1478
|
+
// and no-op if the session changed, so a timer from a dead session can't poke a new one.
|
|
1479
|
+
private var wireSessionGeneration = 0
|
|
1480
|
+
// Grace period before an unanswered BLE wire v2 handshake is re-armed for retry,
|
|
1481
|
+
// and the per-session cap on automatic retries (see sendWireHandshake).
|
|
1482
|
+
private static let wireHandshakeRetryGraceSeconds: TimeInterval = 5
|
|
1483
|
+
private static let wireHandshakeMaxAttempts = 3
|
|
1405
1484
|
// Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to legacy
|
|
1406
1485
|
// big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le (or a
|
|
1407
1486
|
// v2 binary handshake succeeds, which implies wire-v2 LE).
|
|
1408
1487
|
private var peerK900Le = false
|
|
1488
|
+
private var ancsAppIdentifiers: [UInt32: String] = [:]
|
|
1489
|
+
private var ancsRelayEnableRequested = false
|
|
1409
1490
|
private var peerWireCapsBinary = false
|
|
1491
|
+
private var peerFilePayloadV2 = false
|
|
1410
1492
|
private var globalMessageId = 0
|
|
1411
1493
|
private var lastReceivedMessageId = 0
|
|
1412
1494
|
private var bleWriteTraceSequence = 0
|
|
@@ -1641,7 +1723,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1641
1723
|
|
|
1642
1724
|
func requestPhoto(_ request: PhotoRequest) {
|
|
1643
1725
|
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")"
|
|
1726
|
+
"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
1727
|
)
|
|
1646
1728
|
|
|
1647
1729
|
var json: [String: Any] = [
|
|
@@ -1653,7 +1735,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1653
1735
|
let bleImgId =
|
|
1654
1736
|
"I" + String(format: "%09d", Int(Date().timeIntervalSince1970 * 1000) % 100_000_000)
|
|
1655
1737
|
json["bleImgId"] = bleImgId
|
|
1656
|
-
json["transferMethod"] =
|
|
1738
|
+
json["transferMethod"] = request.transferMethod
|
|
1657
1739
|
|
|
1658
1740
|
if let webhookUrl = request.webhookUrl, !webhookUrl.isEmpty {
|
|
1659
1741
|
json["webhookUrl"] = webhookUrl
|
|
@@ -1678,6 +1760,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1678
1760
|
let allowedSizes = ["low", "medium", "high", "max"]
|
|
1679
1761
|
let size = request.size.rawValue
|
|
1680
1762
|
json["size"] = allowedSizes.contains(size) ? size : "medium"
|
|
1763
|
+
json["mode"] = request.mode.rawValue
|
|
1681
1764
|
|
|
1682
1765
|
json["compress"] = request.compress?.rawValue ?? "none"
|
|
1683
1766
|
json["save"] = request.save
|
|
@@ -1693,7 +1776,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1693
1776
|
}
|
|
1694
1777
|
request.appendScanFields(to: &json)
|
|
1695
1778
|
|
|
1696
|
-
Bridge.log(
|
|
1779
|
+
Bridge.log(
|
|
1780
|
+
"LIVE: PHOTO PIPELINE [5b/6] take_photo JSON ready bleImgId=\(bleImgId) transferMethod=\(request.transferMethod)"
|
|
1781
|
+
)
|
|
1697
1782
|
Bridge.log("LIVE: PHOTO PIPELINE [6/6] Dispatching take_photo to sendJson()")
|
|
1698
1783
|
|
|
1699
1784
|
sendJson(json, wakeUp: true)
|
|
@@ -1702,11 +1787,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1702
1787
|
func warmUpCamera(
|
|
1703
1788
|
requestId: String,
|
|
1704
1789
|
size: PhotoSize,
|
|
1790
|
+
mode: PhotoMode = .photo,
|
|
1705
1791
|
exposureTimeNs: Double?,
|
|
1706
1792
|
durationMs: Int
|
|
1707
1793
|
) {
|
|
1708
1794
|
Bridge.log(
|
|
1709
|
-
"LIVE: warmUpCamera() entry requestId=\(requestId) size=\(size.rawValue) durationMs=\(durationMs)"
|
|
1795
|
+
"LIVE: warmUpCamera() entry requestId=\(requestId) size=\(size.rawValue) mode=\(mode.rawValue) durationMs=\(durationMs)"
|
|
1710
1796
|
)
|
|
1711
1797
|
|
|
1712
1798
|
let allowedSizes = ["low", "medium", "high", "max"]
|
|
@@ -1715,6 +1801,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1715
1801
|
"type": "camera_warm_up",
|
|
1716
1802
|
"requestId": requestId,
|
|
1717
1803
|
"size": allowedSizes.contains(sizeRaw) ? sizeRaw : "medium",
|
|
1804
|
+
"mode": mode.rawValue,
|
|
1718
1805
|
"durationMs": durationMs > 0 ? durationMs : 15000,
|
|
1719
1806
|
]
|
|
1720
1807
|
|
|
@@ -1725,6 +1812,13 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1725
1812
|
sendJson(json, wakeUp: true)
|
|
1726
1813
|
}
|
|
1727
1814
|
|
|
1815
|
+
func stopCameraWarmUp(requestId: String) {
|
|
1816
|
+
sendJson(
|
|
1817
|
+
["type": "camera_warm_up_stop", "requestId": requestId],
|
|
1818
|
+
wakeUp: true
|
|
1819
|
+
)
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1728
1822
|
func startStream(_ message: [String: Any]) {
|
|
1729
1823
|
Bridge.log("Starting stream")
|
|
1730
1824
|
var json = message
|
|
@@ -1766,17 +1860,24 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1766
1860
|
// MARK: - Command Queue
|
|
1767
1861
|
|
|
1768
1862
|
class PendingMessage {
|
|
1769
|
-
init(data: Data, id: String, retries: Int, trace: BleWriteTrace? = nil) {
|
|
1863
|
+
init(data: Data, id: String, retries: Int, trace: BleWriteTrace? = nil, generation: Int = 0) {
|
|
1770
1864
|
self.data = data
|
|
1771
1865
|
self.id = id
|
|
1772
1866
|
self.retries = retries
|
|
1773
1867
|
self.trace = trace
|
|
1868
|
+
self.generation = generation
|
|
1774
1869
|
}
|
|
1775
1870
|
|
|
1776
1871
|
let data: Data
|
|
1777
1872
|
let retries: Int
|
|
1778
1873
|
let id: String
|
|
1779
1874
|
let trace: BleWriteTrace?
|
|
1875
|
+
// Wire-session generation at enqueue time; the drain loop drops entries from a
|
|
1876
|
+
// previous session so a stale write (worst case: an old handshake whose reply
|
|
1877
|
+
// would activate v2 pre-negotiation) can never cross a session boundary. This is
|
|
1878
|
+
// the deterministic guard - the async removeAll() on disconnect is best-effort
|
|
1879
|
+
// cleanup that may land after a reconnect has already resumed the drain loop.
|
|
1880
|
+
let generation: Int
|
|
1780
1881
|
}
|
|
1781
1882
|
|
|
1782
1883
|
struct OutgoingBleCommandTraceInfo {
|
|
@@ -1822,6 +1923,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1822
1923
|
guard !commands.isEmpty else { return nil }
|
|
1823
1924
|
return commands.removeFirst()
|
|
1824
1925
|
}
|
|
1926
|
+
|
|
1927
|
+
func removeAll() {
|
|
1928
|
+
commands.removeAll()
|
|
1929
|
+
}
|
|
1825
1930
|
}
|
|
1826
1931
|
|
|
1827
1932
|
private func setupCommandQueue() {
|
|
@@ -1831,7 +1936,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1831
1936
|
let pendingIsNil = await MainActor.run { self.pending == nil }
|
|
1832
1937
|
if pendingIsNil {
|
|
1833
1938
|
if let command = await self.commandQueue.dequeue() {
|
|
1834
|
-
await self.
|
|
1939
|
+
let currentGeneration = await MainActor.run { self.wireSessionGeneration }
|
|
1940
|
+
if command.generation != currentGeneration {
|
|
1941
|
+
Bridge.log("LIVE: Dropping stale queued write from a previous session epoch (id: \(command.id))")
|
|
1942
|
+
} else {
|
|
1943
|
+
await self.processSendQueue(command)
|
|
1944
|
+
}
|
|
1835
1945
|
}
|
|
1836
1946
|
}
|
|
1837
1947
|
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
@@ -1907,7 +2017,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1907
2017
|
data: pendingMessage.data,
|
|
1908
2018
|
id: pendingMessage.id,
|
|
1909
2019
|
retries: pendingMessage.retries + 1,
|
|
1910
|
-
trace: pendingMessage.trace
|
|
2020
|
+
trace: pendingMessage.trace,
|
|
2021
|
+
generation: pendingMessage.generation
|
|
1911
2022
|
)
|
|
1912
2023
|
|
|
1913
2024
|
// Push to front of queue for immediate retry
|
|
@@ -2001,7 +2112,36 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2001
2112
|
// Set connection timeout
|
|
2002
2113
|
startConnectionTimeout()
|
|
2003
2114
|
|
|
2004
|
-
|
|
2115
|
+
// ANCS is hosted by iOS and is only exposed to authorized accessories.
|
|
2116
|
+
// Requiring it at connect time lets the system complete that authorization
|
|
2117
|
+
// flow before the glasses subscribe to the ANCS characteristics.
|
|
2118
|
+
centralManager?.connect(
|
|
2119
|
+
peripheral,
|
|
2120
|
+
options: [CBConnectPeripheralOptionRequiresANCS: true]
|
|
2121
|
+
)
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/// Opt the firmware into ANCS only after iOS has authorized this accessory.
|
|
2125
|
+
/// Old firmware safely ignores the command, while new firmware stays inert
|
|
2126
|
+
/// for old mobile clients that never send it.
|
|
2127
|
+
private func enableAncsRelayIfAuthorized() {
|
|
2128
|
+
guard !ancsRelayEnableRequested,
|
|
2129
|
+
let peripheral = connectedPeripheral,
|
|
2130
|
+
txCharacteristic != nil,
|
|
2131
|
+
rxCharacteristic?.isNotifying == true,
|
|
2132
|
+
peripheral.ancsAuthorized
|
|
2133
|
+
else {
|
|
2134
|
+
return
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
let command: [String: Any] = [
|
|
2138
|
+
"C": "cs_ancs",
|
|
2139
|
+
"B": ["enabled": 1],
|
|
2140
|
+
]
|
|
2141
|
+
if sendRawK900Command(command) {
|
|
2142
|
+
ancsRelayEnableRequested = true
|
|
2143
|
+
Bridge.log("LIVE: Requested ANCS relay from compatible firmware")
|
|
2144
|
+
}
|
|
2005
2145
|
}
|
|
2006
2146
|
|
|
2007
2147
|
private func handleReconnection() {
|
|
@@ -2067,6 +2207,56 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2067
2207
|
)
|
|
2068
2208
|
}
|
|
2069
2209
|
|
|
2210
|
+
// MARK: - L2CAP file channel
|
|
2211
|
+
|
|
2212
|
+
/// Open the BES file CoC. CoreBluetooth does not expose PHY or connection-priority
|
|
2213
|
+
/// controls, so BES owns those link updates when this channel is established.
|
|
2214
|
+
/// Any open failure leaves FILE_READ GATT notifications active as the fallback.
|
|
2215
|
+
private func openL2capFileChannel() {
|
|
2216
|
+
guard !l2capFileChannelOpening, l2capFileChannel == nil else { return }
|
|
2217
|
+
guard let peripheral = connectedPeripheral else { return }
|
|
2218
|
+
|
|
2219
|
+
l2capFileChannelOpening = true
|
|
2220
|
+
Bridge.log("LIVE: L2CAP: opening file channel (PSM 0xC9)")
|
|
2221
|
+
peripheral.openL2CAPChannel(L2CAP_FILE_PSM)
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
private func attachL2capFileChannel(_ channel: CBL2CAPChannel) {
|
|
2225
|
+
closeL2capFileChannel()
|
|
2226
|
+
|
|
2227
|
+
let channelId = UUID()
|
|
2228
|
+
l2capFileChannelId = channelId
|
|
2229
|
+
let reader = MentraLiveL2capChannel(
|
|
2230
|
+
channel: channel,
|
|
2231
|
+
onFileFrame: { [weak self] frame in
|
|
2232
|
+
// The reader thread keeps draining the stream (and returning CoC credits)
|
|
2233
|
+
// while the existing transfer state remains serialized on the main actor.
|
|
2234
|
+
DispatchQueue.main.async {
|
|
2235
|
+
self?.processReceivedData(frame)
|
|
2236
|
+
}
|
|
2237
|
+
},
|
|
2238
|
+
onClose: { [weak self] in
|
|
2239
|
+
DispatchQueue.main.async {
|
|
2240
|
+
guard let self, self.l2capFileChannelId == channelId else { return }
|
|
2241
|
+
self.l2capFileChannel = nil
|
|
2242
|
+
self.l2capFileChannelId = nil
|
|
2243
|
+
Bridge.log("LIVE: L2CAP: channel closed; using GATT fallback")
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
)
|
|
2247
|
+
l2capFileChannel = reader
|
|
2248
|
+
reader.start()
|
|
2249
|
+
Bridge.log("LIVE: L2CAP: channel open (PSM 0xC9)")
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
private func closeL2capFileChannel() {
|
|
2253
|
+
l2capFileChannelOpening = false
|
|
2254
|
+
l2capFileChannelId = nil
|
|
2255
|
+
let reader = l2capFileChannel
|
|
2256
|
+
l2capFileChannel = nil
|
|
2257
|
+
reader?.close()
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2070
2260
|
// MARK: - Data Processing
|
|
2071
2261
|
|
|
2072
2262
|
private func processReceivedData(_ data: Data) {
|
|
@@ -2105,10 +2295,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2105
2295
|
|| commandType == K900ProtocolUtils.CMD_TYPE_AUDIO
|
|
2106
2296
|
|| commandType == K900ProtocolUtils.CMD_TYPE_DATA
|
|
2107
2297
|
{
|
|
2108
|
-
Bridge.log(
|
|
2109
|
-
"📦 DETECTED FILE TRANSFER PACKET (type: 0x\(String(format: "%02X", commandType)))"
|
|
2110
|
-
)
|
|
2111
|
-
|
|
2112
2298
|
// Debug: Log the raw data
|
|
2113
2299
|
// let hexDump = data.prefix(64).map { String(format: "%02X ", $0) }.joined()
|
|
2114
2300
|
// Bridge.log("📦 Raw file packet data length=\(data.count), first 64 bytes: \(hexDump)")
|
|
@@ -2221,7 +2407,14 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2221
2407
|
|
|
2222
2408
|
switch type {
|
|
2223
2409
|
case "glasses_ready":
|
|
2410
|
+
// glasses_ready is a REMOTE wire-session reset: the glasses ran
|
|
2411
|
+
// onTransportReset() before sending it, so their side is back on legacy
|
|
2412
|
+
// framing regardless of what this side negotiated earlier. Start a fresh
|
|
2413
|
+
// wire epoch (clears v2-active state that would otherwise gate the
|
|
2414
|
+
// handshake off), then negotiate from the caps this message advertises.
|
|
2415
|
+
resetWireNegotiationState()
|
|
2224
2416
|
parsePeerWireCaps(json)
|
|
2417
|
+
maybeSendWireHandshake()
|
|
2225
2418
|
handleGlassesReady()
|
|
2226
2419
|
|
|
2227
2420
|
case "battery_status":
|
|
@@ -2256,7 +2449,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2256
2449
|
} else if connected {
|
|
2257
2450
|
DeviceStore.shared.apply("glasses", "wifiError", "")
|
|
2258
2451
|
}
|
|
2259
|
-
updateWifiStatus(connected: connected, ssid: ssid, ip: ip)
|
|
2452
|
+
updateWifiStatus(connected: connected, ssid: ssid, ip: ip, error: wifiError.isEmpty ? nil : wifiError)
|
|
2260
2453
|
|
|
2261
2454
|
case "hotspot_status_update":
|
|
2262
2455
|
let enabled = json["hotspot_enabled"] as? Bool ?? false
|
|
@@ -2500,8 +2693,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2500
2693
|
if let buildNumber = fields["build_number"] as? String {
|
|
2501
2694
|
isNewVersion = (Int(buildNumber) ?? 0) >= 5
|
|
2502
2695
|
DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
|
|
2503
|
-
parsePeerWireCaps(json)
|
|
2504
|
-
maybeSendWireHandshake()
|
|
2505
2696
|
}
|
|
2506
2697
|
if let deviceModel = fields["device_model"] as? String {
|
|
2507
2698
|
DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
|
|
@@ -2533,6 +2724,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2533
2724
|
DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
|
|
2534
2725
|
}
|
|
2535
2726
|
|
|
2727
|
+
// Negotiate wire caps from ANY version_info chunk, not only the one
|
|
2728
|
+
// carrying build_number: the glasses put wire_caps in version_info_3
|
|
2729
|
+
// (firmware chunk) while build_number travels in version_info_1, so
|
|
2730
|
+
// gating negotiation on build_number silently discards the caps.
|
|
2731
|
+
// parsePeerWireCaps no-ops without a wire_caps key and
|
|
2732
|
+
// maybeSendWireHandshake gates on caps + build + not-yet-queued, so
|
|
2733
|
+
// running them per chunk is safe.
|
|
2734
|
+
parsePeerWireCaps(json)
|
|
2735
|
+
maybeSendWireHandshake()
|
|
2736
|
+
|
|
2536
2737
|
Bridge.sendVersionInfo(fields)
|
|
2537
2738
|
} else {
|
|
2538
2739
|
Bridge.log("Unhandled message type: \(type)")
|
|
@@ -2630,6 +2831,11 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2630
2831
|
}
|
|
2631
2832
|
|
|
2632
2833
|
switch command {
|
|
2834
|
+
case "sr_ancs":
|
|
2835
|
+
if let body = k900ParseBody(json["B"]) {
|
|
2836
|
+
processAncsRelay(body)
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2633
2839
|
case "sr_hrt":
|
|
2634
2840
|
if let bodyObj = json["B"] as? [String: Any] {
|
|
2635
2841
|
let readyResponse = bodyObj["ready"] as? Int ?? 0
|
|
@@ -2786,7 +2992,17 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2786
2992
|
|
|
2787
2993
|
let syntheticStatus: String
|
|
2788
2994
|
if besOtaStatus == "FINISHED" {
|
|
2789
|
-
|
|
2995
|
+
// The glasses power-cycle right after the final BES tick, so a session
|
|
2996
|
+
// whose BES step is the LAST step never gets a follow-up ota_status from
|
|
2997
|
+
// the glasses — consumers mapping on this synthetic status would otherwise
|
|
2998
|
+
// never see a terminal state. Emit "complete" for the final step;
|
|
2999
|
+
// mid-session BES steps keep "step_complete" so session-level trackers
|
|
3000
|
+
// advance normally. Unknown sessions (cachedOtaTotalSteps == 0, e.g.
|
|
3001
|
+
// legacy glasses that never sent an ota_status) conservatively keep
|
|
3002
|
+
// "step_complete".
|
|
3003
|
+
syntheticStatus = (cachedOtaTotalSteps > 0 && cachedOtaCurrentStep >= cachedOtaTotalSteps)
|
|
3004
|
+
? "complete"
|
|
3005
|
+
: "step_complete"
|
|
2790
3006
|
} else if besOtaStatus == "FAILED" {
|
|
2791
3007
|
syntheticStatus = "failed"
|
|
2792
3008
|
} else {
|
|
@@ -2834,11 +3050,87 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2834
3050
|
}
|
|
2835
3051
|
}
|
|
2836
3052
|
|
|
3053
|
+
/// Convert a Mentra Live firmware ANCS relay packet into the same native
|
|
3054
|
+
/// events used by Android's NotificationListenerService.
|
|
3055
|
+
private func processAncsRelay(_ body: [String: Any]) {
|
|
3056
|
+
guard let event = body["event"] as? String,
|
|
3057
|
+
let uid = (body["uid"] as? NSNumber)?.uint32Value
|
|
3058
|
+
else {
|
|
3059
|
+
Bridge.log("LIVE: Ignoring malformed ANCS relay packet")
|
|
3060
|
+
return
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
let notificationId = "ancs-\(uid)"
|
|
3064
|
+
let appIdentifier = body["appIdentifier"] as? String ?? ""
|
|
3065
|
+
|
|
3066
|
+
if event == "removed" {
|
|
3067
|
+
let removedAppIdentifier = ancsAppIdentifiers.removeValue(forKey: uid) ?? appIdentifier
|
|
3068
|
+
Bridge.sendTypedMessage(
|
|
3069
|
+
"phone_notification_dismissed",
|
|
3070
|
+
body: [
|
|
3071
|
+
"notificationId": notificationId,
|
|
3072
|
+
"notificationKey": notificationId,
|
|
3073
|
+
"packageName": removedAppIdentifier,
|
|
3074
|
+
"timestamp": Int64(Date().timeIntervalSince1970 * 1000),
|
|
3075
|
+
]
|
|
3076
|
+
)
|
|
3077
|
+
return
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
guard event == "added" || event == "modified" else {
|
|
3081
|
+
Bridge.log("LIVE: Ignoring unknown ANCS event: \(event)")
|
|
3082
|
+
return
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
let title = body["title"] as? String ?? ""
|
|
3086
|
+
let subtitle = body["subtitle"] as? String ?? ""
|
|
3087
|
+
let message = body["message"] as? String ?? ""
|
|
3088
|
+
let content = [subtitle, message]
|
|
3089
|
+
.filter { !$0.isEmpty }
|
|
3090
|
+
.joined(separator: "\n")
|
|
3091
|
+
let flags = (body["flags"] as? NSNumber)?.uint8Value ?? 0
|
|
3092
|
+
if !appIdentifier.isEmpty {
|
|
3093
|
+
ancsAppIdentifiers[uid] = appIdentifier
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
Bridge.sendTypedMessage(
|
|
3097
|
+
"phone_notification",
|
|
3098
|
+
body: [
|
|
3099
|
+
"notificationId": notificationId,
|
|
3100
|
+
"app": appIdentifier,
|
|
3101
|
+
"title": title,
|
|
3102
|
+
"content": content,
|
|
3103
|
+
"priority": (flags & 0x02) != 0 ? "1" : "0",
|
|
3104
|
+
"timestamp": ancsTimestamp(body["date"] as? String),
|
|
3105
|
+
"packageName": appIdentifier,
|
|
3106
|
+
]
|
|
3107
|
+
)
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
private func ancsTimestamp(_ value: String?) -> Int64 {
|
|
3111
|
+
guard let value, !value.isEmpty else {
|
|
3112
|
+
return Int64(Date().timeIntervalSince1970 * 1000)
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
let formatter = DateFormatter()
|
|
3116
|
+
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
3117
|
+
formatter.calendar = Calendar(identifier: .gregorian)
|
|
3118
|
+
formatter.timeZone = .current
|
|
3119
|
+
formatter.dateFormat = "yyyyMMdd'T'HHmmss"
|
|
3120
|
+
guard let date = formatter.date(from: value) else {
|
|
3121
|
+
return Int64(Date().timeIntervalSince1970 * 1000)
|
|
3122
|
+
}
|
|
3123
|
+
return Int64(date.timeIntervalSince1970 * 1000)
|
|
3124
|
+
}
|
|
3125
|
+
|
|
2837
3126
|
// commands to send to the glasses:
|
|
2838
3127
|
|
|
2839
|
-
func requestWifiScan() {
|
|
3128
|
+
func requestWifiScan(scanId: String?) {
|
|
2840
3129
|
Bridge.log("LIVE: Requesting WiFi scan from glasses")
|
|
2841
|
-
|
|
3130
|
+
var json: [String: Any] = ["type": "request_wifi_scan"]
|
|
3131
|
+
if let scanId, !scanId.isEmpty {
|
|
3132
|
+
json["scanId"] = scanId
|
|
3133
|
+
}
|
|
2842
3134
|
sendJson(json, wakeUp: true)
|
|
2843
3135
|
}
|
|
2844
3136
|
|
|
@@ -3020,6 +3312,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3020
3312
|
Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
|
|
3021
3313
|
|
|
3022
3314
|
stopReadinessCheckLoop()
|
|
3315
|
+
advertiseFilePayloadCapabilityToBes()
|
|
3316
|
+
openL2capFileChannel()
|
|
3023
3317
|
sendBleMtuConfig()
|
|
3024
3318
|
|
|
3025
3319
|
// Invalidate any version fields from a prior link session so the next version_info
|
|
@@ -3076,7 +3370,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3076
3370
|
}
|
|
3077
3371
|
|
|
3078
3372
|
let scanComplete = json["scan_complete"] as? Bool ?? json["scanComplete"] as? Bool ?? false
|
|
3079
|
-
|
|
3373
|
+
let scanId = (json["scanId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
3374
|
+
Bridge.updateWifiScanResults(networks, scanComplete: scanComplete, scanId: scanId)
|
|
3080
3375
|
}
|
|
3081
3376
|
|
|
3082
3377
|
private func handleButtonPress(_ json: [String: Any]) {
|
|
@@ -3336,7 +3631,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3336
3631
|
}
|
|
3337
3632
|
|
|
3338
3633
|
if let incidentRelay = bleIncidentLogRelays[bleImgId] {
|
|
3339
|
-
|
|
3634
|
+
if K900ProtocolUtils.shouldLogFilePacket(packetInfo) {
|
|
3635
|
+
Bridge.log("LIVE: 📦 BLE incident log relay packet for: \(bleImgId)")
|
|
3636
|
+
}
|
|
3340
3637
|
|
|
3341
3638
|
if incidentRelay.session == nil {
|
|
3342
3639
|
activeFileTransfers.removeValue(forKey: packetInfo.fileName)
|
|
@@ -3386,7 +3683,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3386
3683
|
|
|
3387
3684
|
if var photoTransfer = blePhotoTransfers[bleImgId] {
|
|
3388
3685
|
// This is a BLE photo transfer
|
|
3389
|
-
|
|
3686
|
+
if K900ProtocolUtils.shouldLogFilePacket(packetInfo) {
|
|
3687
|
+
Bridge.log("📦 BLE photo transfer packet for requestId: \(photoTransfer.requestId)")
|
|
3688
|
+
}
|
|
3390
3689
|
|
|
3391
3690
|
// Get or create session for this transfer
|
|
3392
3691
|
if photoTransfer.session == nil {
|
|
@@ -3482,9 +3781,11 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3482
3781
|
activeFileTransfers[packetInfo.fileName] = sess
|
|
3483
3782
|
|
|
3484
3783
|
if added {
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3784
|
+
if K900ProtocolUtils.shouldLogFilePacket(packetInfo) {
|
|
3785
|
+
Bridge.log(
|
|
3786
|
+
"LIVE: 📦 Packet \(packetInfo.packIndex) received successfully (BES will auto-ACK)"
|
|
3787
|
+
)
|
|
3788
|
+
}
|
|
3488
3789
|
|
|
3489
3790
|
if sess.isComplete {
|
|
3490
3791
|
Bridge.log("LIVE: 📦 File transfer complete: \(packetInfo.fileName)")
|
|
@@ -3754,8 +4055,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3754
4055
|
|
|
3755
4056
|
func queueSend(_ data: Data, id: String, trace: BleWriteTrace?) {
|
|
3756
4057
|
let significantQueueSize = SIGNIFICANT_BLE_TRACE_QUEUE_SIZE
|
|
4058
|
+
// Capture the epoch at enqueue-request time: the Task body may run after a
|
|
4059
|
+
// session reset, and a pre-reset payload stamped with the new generation would
|
|
4060
|
+
// defeat the stale-write guard in the drain loop.
|
|
4061
|
+
let generation = wireSessionGeneration
|
|
3757
4062
|
Task {
|
|
3758
|
-
let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace))
|
|
4063
|
+
let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
|
|
3759
4064
|
guard trace != nil, queueSize >= significantQueueSize else {
|
|
3760
4065
|
return
|
|
3761
4066
|
}
|
|
@@ -3985,19 +4290,47 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3985
4290
|
}
|
|
3986
4291
|
Bridge.log("LIVE: Sending BLE wire v2 handshake")
|
|
3987
4292
|
wireHandshakeQueued = true
|
|
4293
|
+
wireHandshakeSentGeneration = wireSessionGeneration
|
|
3988
4294
|
queueSend(packed, id: "-1")
|
|
4295
|
+
// The handshake and its reply are fire-and-forget binary frames with no ACK
|
|
4296
|
+
// tracking; if either is lost, wireHandshakeQueued would block every future
|
|
4297
|
+
// attempt and the session would silently stay on the legacy string wire.
|
|
4298
|
+
// Re-arm after a grace period so negotiation can retry, bounded per session
|
|
4299
|
+
// so a peer that advertises binary but never answers doesn't get pinged
|
|
4300
|
+
// forever (later caps/version triggers may still retry explicitly).
|
|
4301
|
+
wireHandshakeAttempts += 1
|
|
4302
|
+
let scheduledGeneration = wireSessionGeneration
|
|
4303
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.wireHandshakeRetryGraceSeconds) { [weak self] in
|
|
4304
|
+
guard let self else { return }
|
|
4305
|
+
guard scheduledGeneration == self.wireSessionGeneration else { return }
|
|
4306
|
+
if self.wireHandshakeQueued, self.peerWireProtocolVersion < BleWireProtocol.protocolV2 {
|
|
4307
|
+
Bridge.log(
|
|
4308
|
+
"LIVE: BLE wire v2 handshake unanswered after \(Self.wireHandshakeRetryGraceSeconds)s " +
|
|
4309
|
+
"(attempt \(self.wireHandshakeAttempts)/\(Self.wireHandshakeMaxAttempts)) - re-arming"
|
|
4310
|
+
)
|
|
4311
|
+
self.wireHandshakeQueued = false
|
|
4312
|
+
if self.wireHandshakeAttempts < Self.wireHandshakeMaxAttempts {
|
|
4313
|
+
self.maybeSendWireHandshake()
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
3989
4317
|
}
|
|
3990
4318
|
|
|
3991
4319
|
private func activateBinaryWireV2Session(logMessage: String) {
|
|
3992
4320
|
peerWireProtocolVersion = BleWireProtocol.protocolV2
|
|
3993
4321
|
useBinaryWireProtocol = true
|
|
3994
4322
|
wireHandshakeQueued = false
|
|
4323
|
+
wireHandshakeAttempts = 0
|
|
3995
4324
|
peerK900Le = true
|
|
3996
4325
|
BleJsonCompact.markSessionConnected(epochMs: Int64(Date().timeIntervalSince1970 * 1000))
|
|
3997
4326
|
Bridge.log(logMessage)
|
|
3998
4327
|
}
|
|
3999
4328
|
|
|
4000
4329
|
private func handlePeerWireHandshake() {
|
|
4330
|
+
if wireHandshakeSentGeneration != wireSessionGeneration {
|
|
4331
|
+
Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
|
|
4332
|
+
return
|
|
4333
|
+
}
|
|
4001
4334
|
activateBinaryWireV2Session(logMessage: "LIVE: Peer confirmed BLE wire protocol v2")
|
|
4002
4335
|
}
|
|
4003
4336
|
|
|
@@ -4517,12 +4850,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
4517
4850
|
}
|
|
4518
4851
|
}
|
|
4519
4852
|
|
|
4520
|
-
private func updateWifiStatus(connected: Bool, ssid: String, ip: String) {
|
|
4853
|
+
private func updateWifiStatus(connected: Bool, ssid: String, ip: String, error: String? = nil) {
|
|
4521
4854
|
Bridge.log("LIVE: 🌐 Updating WiFi status - connected: \(connected), ssid: \(ssid)")
|
|
4522
4855
|
DeviceStore.shared.apply("glasses", "wifiConnected", connected)
|
|
4523
4856
|
DeviceStore.shared.apply("glasses", "wifiSsid", ssid)
|
|
4524
4857
|
DeviceStore.shared.apply("glasses", "wifiLocalIp", ip)
|
|
4525
|
-
emitWifiStatusChange()
|
|
4858
|
+
emitWifiStatusChange(error: error)
|
|
4526
4859
|
}
|
|
4527
4860
|
|
|
4528
4861
|
private func updateHotspotStatus(enabled: Bool, ssid: String, password: String, ip: String) {
|
|
@@ -4816,8 +5149,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
4816
5149
|
// emitEvent("BatteryLevelEvent", body: eventBody)
|
|
4817
5150
|
// }
|
|
4818
5151
|
|
|
4819
|
-
private func emitWifiStatusChange() {
|
|
4820
|
-
Bridge.sendWifiStatusChange(connected: wifiConnected, ssid: wifiSsid, localIp: wifiLocalIp)
|
|
5152
|
+
private func emitWifiStatusChange(error: String? = nil) {
|
|
5153
|
+
Bridge.sendWifiStatusChange(connected: wifiConnected, ssid: wifiSsid, localIp: wifiLocalIp, error: error)
|
|
4821
5154
|
}
|
|
4822
5155
|
|
|
4823
5156
|
private func emitHotspotStatusChange() {
|
|
@@ -4899,13 +5232,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
4899
5232
|
|
|
4900
5233
|
// Stop all timers
|
|
4901
5234
|
stopAllTimers()
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
wireHandshakeQueued = false
|
|
4906
|
-
peerK900Le = false
|
|
4907
|
-
peerWireCapsBinary = false
|
|
4908
|
-
BleJsonCompact.resetSession()
|
|
5235
|
+
resetWireNegotiationState()
|
|
5236
|
+
Task { await commandQueue.removeAll() } // stale writes die with the session
|
|
5237
|
+
closeL2capFileChannel()
|
|
4909
5238
|
|
|
4910
5239
|
// Disconnect BLE
|
|
4911
5240
|
if let peripheral = connectedPeripheral {
|
|
@@ -4997,6 +5326,34 @@ extension MentraLive {
|
|
|
4997
5326
|
* per-link endianness and binary support. Missing wire_caps leaves the legacy defaults (BE, no
|
|
4998
5327
|
* binary) untouched so older glasses keep working.
|
|
4999
5328
|
*/
|
|
5329
|
+
/// Drop every negotiated wire-session artifact and bump the session generation so
|
|
5330
|
+
/// scheduled callbacks from the old session stand down. Called on BLE disconnect and on
|
|
5331
|
+
/// glasses_ready: the glasses run onTransportReset() before sending glasses_ready, so a
|
|
5332
|
+
/// mid-link ASG restart (no BLE disconnect) resets THEIR side to legacy framing - the
|
|
5333
|
+
/// phone must treat every glasses_ready as a new wire epoch and renegotiate (bounded by
|
|
5334
|
+
/// the per-session handshake attempt cap) instead of trusting stale v2-active state.
|
|
5335
|
+
private func resetWireNegotiationState() {
|
|
5336
|
+
incomingChunkReassembler.clear()
|
|
5337
|
+
peerWireProtocolVersion = 0
|
|
5338
|
+
useBinaryWireProtocol = false
|
|
5339
|
+
wireHandshakeQueued = false
|
|
5340
|
+
wireHandshakeAttempts = 0
|
|
5341
|
+
wireSessionGeneration += 1
|
|
5342
|
+
peerK900Le = false
|
|
5343
|
+
peerWireCapsBinary = false
|
|
5344
|
+
peerFilePayloadV2 = false
|
|
5345
|
+
BleJsonCompact.resetSession()
|
|
5346
|
+
wireHandshakeSentGeneration = -1
|
|
5347
|
+
}
|
|
5348
|
+
|
|
5349
|
+
/// ANCS notification IDs live for the BLE link, not the wire epoch. A
|
|
5350
|
+
/// glasses_ready message may reset wire negotiation without disconnecting,
|
|
5351
|
+
/// so clear this state only when the BLE session itself ends.
|
|
5352
|
+
private func resetAncsRelayState() {
|
|
5353
|
+
ancsAppIdentifiers.removeAll()
|
|
5354
|
+
ancsRelayEnableRequested = false
|
|
5355
|
+
}
|
|
5356
|
+
|
|
5000
5357
|
private func parsePeerWireCaps(_ json: [String: Any]) {
|
|
5001
5358
|
guard let caps = json["wire_caps"] as? [String: Any] else { return }
|
|
5002
5359
|
if (caps["k900_le"] as? Bool) == true {
|
|
@@ -5008,6 +5365,20 @@ extension MentraLive {
|
|
|
5008
5365
|
if caps.keys.contains("binary") {
|
|
5009
5366
|
peerWireCapsBinary = (caps["binary"] as? Bool) == true
|
|
5010
5367
|
}
|
|
5368
|
+
if caps.keys.contains("file_payload_v2") {
|
|
5369
|
+
peerFilePayloadV2 = (caps["file_payload_v2"] as? Bool) == true
|
|
5370
|
+
}
|
|
5371
|
+
}
|
|
5372
|
+
|
|
5373
|
+
private func advertiseFilePayloadCapabilityToBes() {
|
|
5374
|
+
guard peerFilePayloadV2 else { return }
|
|
5375
|
+
let body = "{\"max\":\(K900ProtocolUtils.FILE_PACK_SIZE)}"
|
|
5376
|
+
let command: [String: Any] = ["C": "cs_file_payload", "B": body]
|
|
5377
|
+
if sendRawK900Command(command) {
|
|
5378
|
+
Bridge.log(
|
|
5379
|
+
"LIVE: 📦 Advertised file payload ceiling \(K900ProtocolUtils.FILE_PACK_SIZE) to BES"
|
|
5380
|
+
)
|
|
5381
|
+
}
|
|
5011
5382
|
}
|
|
5012
5383
|
|
|
5013
5384
|
/**
|
|
@@ -5877,8 +6248,8 @@ extension MentraLive {
|
|
|
5877
6248
|
}
|
|
5878
6249
|
|
|
5879
6250
|
func sendCameraFovSetting() {
|
|
5880
|
-
let settings = DeviceStore.shared.get("bluetooth", "camera_fov") as? [String: Any] ?? ["fov":
|
|
5881
|
-
let fov = settings["fov"] as? Int ??
|
|
6251
|
+
let settings = DeviceStore.shared.get("bluetooth", "camera_fov") as? [String: Any] ?? ["fov": 102, "roi_position": 0]
|
|
6252
|
+
let fov = settings["fov"] as? Int ?? 102
|
|
5882
6253
|
let roiPosition = settings["roi_position"] as? Int ?? 0
|
|
5883
6254
|
sendCameraFovSetting(requestId: nil, fov: fov, roiPosition: roiPosition)
|
|
5884
6255
|
}
|
|
@@ -5904,6 +6275,39 @@ extension MentraLive {
|
|
|
5904
6275
|
sendJson(json, wakeUp: true)
|
|
5905
6276
|
}
|
|
5906
6277
|
|
|
6278
|
+
func sendCameraFovOverride(
|
|
6279
|
+
requestId: String,
|
|
6280
|
+
leaseId: String,
|
|
6281
|
+
fov: Int,
|
|
6282
|
+
roiPosition: Int,
|
|
6283
|
+
ttlMs: Int
|
|
6284
|
+
) {
|
|
6285
|
+
sendJson(
|
|
6286
|
+
[
|
|
6287
|
+
"type": "camera_fov_override",
|
|
6288
|
+
"request_id": requestId,
|
|
6289
|
+
"params": [
|
|
6290
|
+
"lease_id": leaseId,
|
|
6291
|
+
"fov": fov,
|
|
6292
|
+
"roi_position": roiPosition,
|
|
6293
|
+
"ttl_ms": ttlMs,
|
|
6294
|
+
],
|
|
6295
|
+
],
|
|
6296
|
+
wakeUp: true
|
|
6297
|
+
)
|
|
6298
|
+
}
|
|
6299
|
+
|
|
6300
|
+
func releaseCameraFovOverride(requestId: String, leaseId: String) {
|
|
6301
|
+
sendJson(
|
|
6302
|
+
[
|
|
6303
|
+
"type": "camera_fov_override_release",
|
|
6304
|
+
"request_id": requestId,
|
|
6305
|
+
"params": ["lease_id": leaseId],
|
|
6306
|
+
],
|
|
6307
|
+
wakeUp: true
|
|
6308
|
+
)
|
|
6309
|
+
}
|
|
6310
|
+
|
|
5907
6311
|
func sendCameraTuningConfig(requestId: String?, anrOn: Bool, gainOn: Bool) {
|
|
5908
6312
|
Bridge.log("Sending camera tuning config: anr=\(anrOn), gain=\(gainOn)")
|
|
5909
6313
|
|