@mentra/bluetooth-sdk 0.1.18 → 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 (77) 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 +120 -17
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +34 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +551 -159
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -5
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +109 -73
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  27. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -9
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  31. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  36. package/build/BluetoothSdk.types.d.ts +37 -1
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  40. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  43. package/build/_private/photoRequestPayload.js +1 -0
  44. package/build/_private/photoRequestPayload.js.map +1 -1
  45. package/build/index.d.ts +1 -1
  46. package/build/index.d.ts.map +1 -1
  47. package/build/index.js +5 -0
  48. package/build/index.js.map +1 -1
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -0
  51. package/build/types/index.js +2 -0
  52. package/build/types/index.js.map +1 -0
  53. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  54. package/ios/Source/DeviceManager.swift +136 -30
  55. package/ios/Source/DeviceStore.swift +5 -5
  56. package/ios/Source/MentraBluetoothSDK.swift +40 -24
  57. package/ios/Source/OtaManifest.swift +1 -1
  58. package/ios/Source/camera/CameraModels.swift +17 -3
  59. package/ios/Source/sgcs/G1.swift +1 -1
  60. package/ios/Source/sgcs/G2.swift +518 -11
  61. package/ios/Source/sgcs/MentraLive.swift +443 -42
  62. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  63. package/ios/Source/sgcs/MentraNex.swift +416 -32
  64. package/ios/Source/sgcs/SGCManager.swift +155 -0
  65. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  66. package/ios/Source/status/DeviceStatus.swift +0 -8
  67. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  68. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  69. package/ios/Source/utils/G1Text.swift +30 -1
  70. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  71. package/ios/Source/utils/MessageChunker.swift +73 -0
  72. package/package.json +14 -7
  73. package/src/BluetoothSdk.types.ts +39 -1
  74. package/src/_private/BluetoothSdkModule.ts +4 -0
  75. package/src/_private/photoRequestPayload.ts +1 -0
  76. package/src/index.ts +6 -0
  77. package/src/types/index.ts +7 -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,6 +125,8 @@ class BlePhotoUploadService {
118
125
  "\(TAG): Decoded image to bitmap: \(Int(image.size.width))x\(Int(image.size.height))"
119
126
  )
120
127
 
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.
121
130
  guard var jpegData = image.jpegData(compressionQuality: 0.9) else {
122
131
  throw NSError(
123
132
  domain: "BlePhotoUpload",
@@ -145,6 +154,11 @@ class BlePhotoUploadService {
145
154
  )
146
155
  }
147
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
+
148
162
  private static func describeContainer(_ data: Data) -> String {
149
163
  let bytes = [UInt8](data.prefix(12))
150
164
  if bytes.count >= 2, bytes[0] == 0xFF, bytes[1] == 0xD8 { return "jpeg" }
@@ -492,11 +506,12 @@ extension Data {
492
506
  }
493
507
  }
494
508
 
495
- private enum K900ProtocolUtils {
509
+ enum K900ProtocolUtils {
496
510
  // Protocol constants
497
511
  static let CMD_START_CODE: [UInt8] = [0x23, 0x23] // ##
498
512
  static let CMD_END_CODE: [UInt8] = [0x24, 0x24] // $$
499
513
  static let CMD_TYPE_STRING: UInt8 = 0x30 // String/JSON type
514
+ static let CMD_TYPE_BINARY_MSG: UInt8 = 0x40
500
515
 
501
516
  // JSON Field constants
502
517
  static let FIELD_C = "C" // Command/Content field
@@ -511,7 +526,9 @@ private enum K900ProtocolUtils {
511
526
  static let CMD_TYPE_DATA: UInt8 = 0x35
512
527
 
513
528
  // File transfer constants
514
- 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
515
532
  static let LENGTH_FILE_START = 2
516
533
  static let LENGTH_FILE_TYPE = 1
517
534
  static let LENGTH_FILE_PACKSIZE = 2
@@ -614,7 +631,7 @@ private enum K900ProtocolUtils {
614
631
  print(
615
632
  "K900ProtocolUtils: File packet checksum failed. Expected: \(String(format: "%02X", info.verifyCode)), Calculated: \(String(format: "%02X", calculatedVerify))"
616
633
  )
617
- } else {
634
+ } else if shouldLogFilePacket(info) {
618
635
  print(
619
636
  "K900ProtocolUtils: File packet extracted successfully: index=\(info.packIndex), size=\(info.packSize), fileName=\(info.fileName)"
620
637
  )
@@ -622,6 +639,14 @@ private enum K900ProtocolUtils {
622
639
 
623
640
  return info
624
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
+ }
625
650
  }
626
651
 
627
652
  private struct FileTransferSession {
@@ -917,6 +942,7 @@ extension MentraLive: CBCentralManagerDelegate {
917
942
  self.rgbLedAuthorityClaimed = false
918
943
 
919
944
  self.stopAllTimers()
945
+ self.closeL2capFileChannel()
920
946
 
921
947
  // Clean up characteristics
922
948
  self.txCharacteristic = nil
@@ -937,6 +963,7 @@ extension MentraLive: CBCentralManagerDelegate {
937
963
 
938
964
  self.stopConnectionTimeout()
939
965
  self.isConnecting = false
966
+ self.closeL2capFileChannel()
940
967
  self.connectedPeripheral = nil
941
968
  self.updateConnectionState(ConnTypes.DISCONNECTED)
942
969
 
@@ -1084,6 +1111,29 @@ extension MentraLive: CBPeripheralDelegate {
1084
1111
  }
1085
1112
  }
1086
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
+
1087
1137
  nonisolated func peripheral(
1088
1138
  _: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
1089
1139
  ) {
@@ -1218,6 +1268,13 @@ class MentraLive: NSObject, SGCManager {
1218
1268
  // or stale lastBesOtaProgress on the next OTA).
1219
1269
  if state == ConnTypes.DISCONNECTED {
1220
1270
  incomingChunkReassembler.clear()
1271
+ peerWireProtocolVersion = 0
1272
+ useBinaryWireProtocol = false
1273
+ wireHandshakeQueued = false
1274
+ peerK900Le = false
1275
+ peerWireCapsBinary = false
1276
+ peerFilePayloadV2 = false
1277
+ BleJsonCompact.resetSession()
1221
1278
  stopSignalStrengthPolling()
1222
1279
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
1223
1280
  DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
@@ -1318,6 +1375,7 @@ class MentraLive: NSObject, SGCManager {
1318
1375
  private let TX_CHAR_UUID = CBUUID(string: "000071FF-0000-1000-8000-00805f9b34fb") // Central transmits on peripheral's RX
1319
1376
  private let FILE_READ_UUID = CBUUID(string: "000072FF-0000-1000-8000-00805f9b34fb")
1320
1377
  private let FILE_WRITE_UUID = CBUUID(string: "000073FF-0000-1000-8000-00805f9b34fb")
1378
+ private let L2CAP_FILE_PSM: CBL2CAPPSM = 0x00C9
1321
1379
 
1322
1380
  // LC3 Audio UUIDs (for K901+ devices with microphone support)
1323
1381
  private let LC3_READ_UUID = CBUUID(string: "6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
@@ -1331,6 +1389,9 @@ class MentraLive: NSObject, SGCManager {
1331
1389
  private var activeFileTransfers = [String: FileTransferSession]()
1332
1390
  private var blePhotoTransfers = [String: BlePhotoTransfer]()
1333
1391
  private var bleIncidentLogRelays = [String: BleIncidentLogRelayEntry]()
1392
+ private var l2capFileChannel: MentraLiveL2capChannel?
1393
+ private var l2capFileChannelId: UUID?
1394
+ private var l2capFileChannelOpening = false
1334
1395
  private var rgbLedAuthorityClaimed = false
1335
1396
 
1336
1397
  // LC3 Audio properties
@@ -1380,7 +1441,7 @@ class MentraLive: NSObject, SGCManager {
1380
1441
  private var connectedPeripheral: CBPeripheral?
1381
1442
  private var txCharacteristic: CBCharacteristic?
1382
1443
  private var rxCharacteristic: CBCharacteristic?
1383
- private let bes2700MtuLimit = 256
1444
+ private let bes2700MtuLimit = 509
1384
1445
  private var currentMtu: Int = 23 // Default BLE MTU
1385
1446
 
1386
1447
  // State Tracking
@@ -1389,6 +1450,15 @@ class MentraLive: NSObject, SGCManager {
1389
1450
  private var isKilled = false
1390
1451
  private var reconnectAttempts = 0
1391
1452
  private var isNewVersion = false
1453
+ private var peerWireProtocolVersion = 0
1454
+ private var useBinaryWireProtocol = false
1455
+ private var wireHandshakeQueued = false
1456
+ // Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to legacy
1457
+ // big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le (or a
1458
+ // v2 binary handshake succeeds, which implies wire-v2 LE).
1459
+ private var peerK900Le = false
1460
+ private var peerWireCapsBinary = false
1461
+ private var peerFilePayloadV2 = false
1392
1462
  private var globalMessageId = 0
1393
1463
  private var lastReceivedMessageId = 0
1394
1464
  private var bleWriteTraceSequence = 0
@@ -1513,7 +1583,7 @@ class MentraLive: NSObject, SGCManager {
1513
1583
  Bridge.log("Found already-connected peripheral: \(peripheral.name ?? "Unknown")")
1514
1584
  if let name = peripheral.name,
1515
1585
  name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
1516
- || name.hasPrefix("MENTRA_LIVE_BT")
1586
+ || name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
1517
1587
  {
1518
1588
  Bridge.log("Found already-connected peripheral: \(name)")
1519
1589
  discoveredPeripherals[name] = peripheral
@@ -1623,7 +1693,7 @@ class MentraLive: NSObject, SGCManager {
1623
1693
 
1624
1694
  func requestPhoto(_ request: PhotoRequest) {
1625
1695
  Bridge.log(
1626
- "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")"
1627
1697
  )
1628
1698
 
1629
1699
  var json: [String: Any] = [
@@ -1660,6 +1730,7 @@ class MentraLive: NSObject, SGCManager {
1660
1730
  let allowedSizes = ["low", "medium", "high", "max"]
1661
1731
  let size = request.size.rawValue
1662
1732
  json["size"] = allowedSizes.contains(size) ? size : "medium"
1733
+ json["mode"] = request.mode.rawValue
1663
1734
 
1664
1735
  json["compress"] = request.compress?.rawValue ?? "none"
1665
1736
  json["save"] = request.save
@@ -2049,6 +2120,56 @@ class MentraLive: NSObject, SGCManager {
2049
2120
  )
2050
2121
  }
2051
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
+
2052
2173
  // MARK: - Data Processing
2053
2174
 
2054
2175
  private func processReceivedData(_ data: Data) {
@@ -2087,10 +2208,6 @@ class MentraLive: NSObject, SGCManager {
2087
2208
  || commandType == K900ProtocolUtils.CMD_TYPE_AUDIO
2088
2209
  || commandType == K900ProtocolUtils.CMD_TYPE_DATA
2089
2210
  {
2090
- Bridge.log(
2091
- "📦 DETECTED FILE TRANSFER PACKET (type: 0x\(String(format: "%02X", commandType)))"
2092
- )
2093
-
2094
2211
  // Debug: Log the raw data
2095
2212
  // let hexDump = data.prefix(64).map { String(format: "%02X ", $0) }.joined()
2096
2213
  // Bridge.log("📦 Raw file packet data length=\(data.count), first 64 bytes: \(hexDump)")
@@ -2106,17 +2223,17 @@ class MentraLive: NSObject, SGCManager {
2106
2223
  return // Exit after processing file packet
2107
2224
  }
2108
2225
 
2109
- let payloadLength: Int
2226
+ if commandType == K900ProtocolUtils.CMD_TYPE_BINARY_MSG {
2227
+ processBinaryWireFrame(data)
2228
+ return
2229
+ }
2110
2230
 
2111
- // Determine endianness based on device name
2112
- if let deviceName = connectedPeripheral?.name,
2113
- deviceName.hasPrefix("XyBLE_") || deviceName.lowercased().hasPrefix("mentra_live")
2114
- {
2115
- // K900 device - big-endian
2116
- payloadLength = (Int(bytes[3]) << 8) | Int(bytes[4])
2117
- } else {
2118
- // Standard device - little-endian
2119
- payloadLength = (Int(bytes[4]) << 8) | Int(bytes[3])
2231
+ // Auto-detect the length endianness so we parse both legacy big-endian and wire-v2
2232
+ // little-endian frames, and learn the glasses' endianness for future outbound frames.
2233
+ let detected = k900DetectStringLength(bytes)
2234
+ let payloadLength = detected?.length ?? (Int(bytes[3]) | (Int(bytes[4]) << 8))
2235
+ if let detected {
2236
+ peerK900Le = detected.isLe
2120
2237
  }
2121
2238
 
2122
2239
  // Bridge.log(
@@ -2150,7 +2267,11 @@ class MentraLive: NSObject, SGCManager {
2150
2267
  }
2151
2268
  }
2152
2269
 
2153
- private func processJsonObject(_ json: [String: Any]) {
2270
+ private func processJsonObject(_ incoming: [String: Any]) {
2271
+ guard let json = expandCompactWireJson(incoming) else {
2272
+ Bridge.log("LIVE: Rejected unsupported compact wire form")
2273
+ return
2274
+ }
2154
2275
  // Log ALL incoming JSON objects for debugging
2155
2276
  // Bridge.log("LIVE: DEBUG: processJsonObject: \(json)")
2156
2277
  BleTraceLogger.logJson(direction: "glasses_to_phone", layer: "sdk_ble_event", payload: json)
@@ -2199,12 +2320,16 @@ class MentraLive: NSObject, SGCManager {
2199
2320
 
2200
2321
  switch type {
2201
2322
  case "glasses_ready":
2323
+ parsePeerWireCaps(json)
2202
2324
  handleGlassesReady()
2203
2325
 
2204
2326
  case "battery_status":
2205
- let level = json["level"] as? Int ?? batteryLevel
2206
- let isCharging = json["charging"] as? Bool ?? charging
2207
- updateBatteryStatus(level: level, isCharging: isCharging)
2327
+ // Percent only (the glasses send it as "percent"; the old "level" read never
2328
+ // matched). battery_status derives from hm_batv, which carries no charge bit —
2329
+ // older glasses fabricate `charging` from a voltage threshold. Charging state
2330
+ // comes exclusively from the PMU charg bit in the sr_hrt heartbeat.
2331
+ let level = json["percent"] as? Int ?? json["level"] as? Int ?? batteryLevel
2332
+ updateBatteryStatus(level: level, isCharging: charging)
2208
2333
 
2209
2334
  case "voice_activity_detection_status":
2210
2335
  let enabled = json["voiceActivityDetectionEnabled"] as? Bool
@@ -2219,6 +2344,17 @@ class MentraLive: NSObject, SGCManager {
2219
2344
  let connected = json["connected"] as? Bool ?? false
2220
2345
  let ssid = json["ssid"] as? String ?? ""
2221
2346
  let ip = json["local_ip"] as? String ?? ""
2347
+ // Provisioning failure reason (e.g. connect_timeout). Sticky: routine
2348
+ // error-less status updates (link-state debounce, request_wifi_status)
2349
+ // must not clear a failure nothing recovered from — only a newer error
2350
+ // or a successful connection overwrites it.
2351
+ let wifiError = json["error"] as? String ?? ""
2352
+ if !wifiError.isEmpty {
2353
+ Bridge.log("LIVE: 🌐 WiFi provisioning error from glasses: \(wifiError)")
2354
+ DeviceStore.shared.apply("glasses", "wifiError", wifiError)
2355
+ } else if connected {
2356
+ DeviceStore.shared.apply("glasses", "wifiError", "")
2357
+ }
2222
2358
  updateWifiStatus(connected: connected, ssid: ssid, ip: ip)
2223
2359
 
2224
2360
  case "hotspot_status_update":
@@ -2463,6 +2599,8 @@ class MentraLive: NSObject, SGCManager {
2463
2599
  if let buildNumber = fields["build_number"] as? String {
2464
2600
  isNewVersion = (Int(buildNumber) ?? 0) >= 5
2465
2601
  DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
2602
+ parsePeerWireCaps(json)
2603
+ maybeSendWireHandshake()
2466
2604
  }
2467
2605
  if let deviceModel = fields["device_model"] as? String {
2468
2606
  DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
@@ -2595,10 +2733,13 @@ class MentraLive: NSObject, SGCManager {
2595
2733
  if let bodyObj = json["B"] as? [String: Any] {
2596
2734
  let readyResponse = bodyObj["ready"] as? Int ?? 0
2597
2735
 
2598
- // Extract battery info from heartbeat
2736
+ // Extract battery info from heartbeat. charg is the PMU charging bit — the
2737
+ // only truthful charging source in the protocol. Old firmware omits it;
2738
+ // keep the last known state then instead of defaulting to not-charging.
2599
2739
  let percentage = bodyObj["pt"] as? Int ?? 0
2600
2740
  let voltage = bodyObj["vt"] as? Int ?? 0
2601
- let charging = (bodyObj["charg"] as? Int ?? 0) == 1
2741
+ let chargBit = bodyObj["charg"] as? Int
2742
+ let charging = chargBit != nil ? (chargBit == 1) : self.charging
2602
2743
 
2603
2744
  // SOC is still booting
2604
2745
  if readyResponse == 0 {
@@ -2646,12 +2787,15 @@ class MentraLive: NSObject, SGCManager {
2646
2787
  let percentage = body["pt"] as? Int
2647
2788
  {
2648
2789
  let voltageVolts = Double(voltage) / 1000.0
2649
- let isCharging = voltage > 4000
2650
2790
 
2651
2791
  Bridge.log(
2652
2792
  "🔋 K900 Battery Status - Voltage: \(voltageVolts)V, Level: \(percentage)%"
2653
2793
  )
2654
- updateBatteryStatus(level: percentage, isCharging: isCharging)
2794
+ // Percent only. sr_batv carries just voltage+percent; inferring charging
2795
+ // from voltage (>4.0V) reads "not charging" for most of a genuinely-charging
2796
+ // pack's range. Charging state comes exclusively from the PMU charg bit in
2797
+ // the sr_hrt heartbeat.
2798
+ updateBatteryStatus(level: percentage, isCharging: charging)
2655
2799
  }
2656
2800
 
2657
2801
  case "sr_getvol":
@@ -2975,6 +3119,8 @@ class MentraLive: NSObject, SGCManager {
2975
3119
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
2976
3120
 
2977
3121
  stopReadinessCheckLoop()
3122
+ advertiseFilePayloadCapabilityToBes()
3123
+ openL2capFileChannel()
2978
3124
  sendBleMtuConfig()
2979
3125
 
2980
3126
  // Invalidate any version fields from a prior link session so the next version_info
@@ -3057,6 +3203,7 @@ class MentraLive: NSObject, SGCManager {
3057
3203
  DeviceStore.shared.apply("glasses", "firmwareVersion", firmwareVersion)
3058
3204
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
3059
3205
  isNewVersion = (Int(buildNumber) ?? 0) >= 5
3206
+ maybeSendWireHandshake()
3060
3207
  DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
3061
3208
  DeviceStore.shared.apply("glasses", "androidVersion", androidVersion)
3062
3209
 
@@ -3290,7 +3437,9 @@ class MentraLive: NSObject, SGCManager {
3290
3437
  }
3291
3438
 
3292
3439
  if let incidentRelay = bleIncidentLogRelays[bleImgId] {
3293
- 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
+ }
3294
3443
 
3295
3444
  if incidentRelay.session == nil {
3296
3445
  activeFileTransfers.removeValue(forKey: packetInfo.fileName)
@@ -3340,7 +3489,9 @@ class MentraLive: NSObject, SGCManager {
3340
3489
 
3341
3490
  if var photoTransfer = blePhotoTransfers[bleImgId] {
3342
3491
  // This is a BLE photo transfer
3343
- 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
+ }
3344
3495
 
3345
3496
  // Get or create session for this transfer
3346
3497
  if photoTransfer.session == nil {
@@ -3436,9 +3587,11 @@ class MentraLive: NSObject, SGCManager {
3436
3587
  activeFileTransfers[packetInfo.fileName] = sess
3437
3588
 
3438
3589
  if added {
3439
- Bridge.log(
3440
- "LIVE: 📦 Packet \(packetInfo.packIndex) received successfully (BES will auto-ACK)"
3441
- )
3590
+ if K900ProtocolUtils.shouldLogFilePacket(packetInfo) {
3591
+ Bridge.log(
3592
+ "LIVE: 📦 Packet \(packetInfo.packIndex) received successfully (BES will auto-ACK)"
3593
+ )
3594
+ }
3442
3595
 
3443
3596
  if sess.isComplete {
3444
3597
  Bridge.log("LIVE: 📦 File transfer complete: \(packetInfo.fileName)")
@@ -3566,7 +3719,7 @@ class MentraLive: NSObject, SGCManager {
3566
3719
  let basePath = components.path.hasSuffix("/")
3567
3720
  ? String(components.path.dropLast())
3568
3721
  : components.path
3569
- components.path = basePath + "/api/incidents/\(relay.incidentId)/logs"
3722
+ components.path = basePath + "/api/client/reports/\(relay.incidentId)/artifacts"
3570
3723
  guard let url = components.url else {
3571
3724
  sendTransferCompleteConfirmation(fileName: fileName, success: false)
3572
3725
  if let existing = bleIncidentLogRelays[relay.fileBaseKey] {
@@ -3911,6 +4064,162 @@ class MentraLive: NSObject, SGCManager {
3911
4064
  }
3912
4065
  }
3913
4066
 
4067
+ private func maybeSendWireHandshake() {
4068
+ // Only attempt the v2 binary handshake once the glasses have advertised binary support via
4069
+ // wire_caps. Older builds that report new version but lack wire_caps stay on the legacy path.
4070
+ guard isNewVersion,
4071
+ peerWireCapsBinary,
4072
+ !wireHandshakeQueued,
4073
+ peerWireProtocolVersion < BleWireProtocol.protocolV2
4074
+ else { return }
4075
+ sendWireHandshake()
4076
+ }
4077
+
4078
+ private func sendWireHandshake() {
4079
+ guard isNewVersion else { return }
4080
+ var flags = BleWireProtocol.flagHandshake
4081
+ flags |= BleWireProtocol.flagFirstFrag
4082
+ flags |= BleWireProtocol.flagLastFrag
4083
+ let payload = Data(BleWireProtocol.handshakePayloadV2.utf8)
4084
+ guard let packed = BleWireProtocol.packBinaryFragment(
4085
+ flags: flags,
4086
+ msgId: 0,
4087
+ fragIdx: 0,
4088
+ fragCount: 1,
4089
+ payload: payload
4090
+ ) else {
4091
+ return
4092
+ }
4093
+ Bridge.log("LIVE: Sending BLE wire v2 handshake")
4094
+ wireHandshakeQueued = true
4095
+ queueSend(packed, id: "-1")
4096
+ }
4097
+
4098
+ private func activateBinaryWireV2Session(logMessage: String) {
4099
+ peerWireProtocolVersion = BleWireProtocol.protocolV2
4100
+ useBinaryWireProtocol = true
4101
+ wireHandshakeQueued = false
4102
+ peerK900Le = true
4103
+ BleJsonCompact.markSessionConnected(epochMs: Int64(Date().timeIntervalSince1970 * 1000))
4104
+ Bridge.log(logMessage)
4105
+ }
4106
+
4107
+ private func handlePeerWireHandshake() {
4108
+ activateBinaryWireV2Session(logMessage: "LIVE: Peer confirmed BLE wire protocol v2")
4109
+ }
4110
+
4111
+ private func processBinaryWireFrame(_ data: Data) {
4112
+ guard let info = BleWireProtocol.extractBinaryFragmentInfo(data) else {
4113
+ Bridge.log("LIVE: Failed to parse binary wire frame")
4114
+ return
4115
+ }
4116
+
4117
+ if BleWireProtocol.isHandshakeV2(info) {
4118
+ handlePeerWireHandshake()
4119
+ return
4120
+ }
4121
+
4122
+ if !useBinaryWireProtocol, isNewVersion {
4123
+ activateBinaryWireV2Session(logMessage: "LIVE: Auto-enabled BLE wire v2 from incoming binary frame")
4124
+ }
4125
+
4126
+ guard let reassembled = incomingChunkReassembler.addBinaryFragment(
4127
+ msgId: info.msgId,
4128
+ fragIdx: Int(info.fragIdx),
4129
+ fragCount: Int(info.fragCount),
4130
+ data: info.payload
4131
+ ) else {
4132
+ return
4133
+ }
4134
+
4135
+ guard let jsonString = String(data: reassembled, encoding: .utf8) else {
4136
+ Bridge.log("LIVE: Failed to decode reassembled binary wire payload")
4137
+ return
4138
+ }
4139
+
4140
+ logWireMetrics(
4141
+ payloadBytes: reassembled.count,
4142
+ wireBytes: data.count,
4143
+ packetCount: Int(info.fragCount),
4144
+ protocolVersion: BleWireProtocol.protocolV2,
4145
+ direction: "glasses_to_phone"
4146
+ )
4147
+ processJsonMessage(jsonString)
4148
+ }
4149
+
4150
+ private func compactWireJson(_ json: [String: Any]) -> [String: Any] {
4151
+ guard useBinaryWireProtocol, isNewVersion else { return json }
4152
+ return BleJsonCompact.encode(json)
4153
+ }
4154
+
4155
+ private func expandCompactWireJson(_ json: [String: Any]) -> [String: Any]? {
4156
+ guard useBinaryWireProtocol, isNewVersion else { return json }
4157
+ return BleJsonCompact.decodeIfSupported(json)
4158
+ }
4159
+
4160
+ private func logWireMetrics(
4161
+ payloadBytes: Int,
4162
+ wireBytes: Int,
4163
+ packetCount: Int,
4164
+ protocolVersion: Int,
4165
+ direction: String
4166
+ ) {
4167
+ Bridge.log(
4168
+ "BLE_TRACE direction=\(direction) proto=v\(protocolVersion) payload=\(payloadBytes) wire=\(wireBytes) packets=\(packetCount)"
4169
+ )
4170
+ }
4171
+
4172
+ private func sendJsonBinary(
4173
+ jsonString: String,
4174
+ messageId: Int64,
4175
+ trackingId: String,
4176
+ wakeUp: Bool,
4177
+ requireAck: Bool
4178
+ ) {
4179
+ let payload = Data(jsonString.utf8)
4180
+ let msgId = UInt16(truncatingIfNeeded: messageId)
4181
+ let fragments = MessageChunker.createBinaryFragments(
4182
+ payload: payload,
4183
+ msgId: msgId,
4184
+ wakeUp: wakeUp,
4185
+ ackRequested: requireAck && messageId >= 0
4186
+ )
4187
+ guard !fragments.isEmpty else {
4188
+ Bridge.log("LIVE: Failed to create binary wire fragments")
4189
+ return
4190
+ }
4191
+
4192
+ var totalWireBytes = 0
4193
+ for (index, fragment) in fragments.enumerated() {
4194
+ guard let packed = BleWireProtocol.packBinaryFragment(
4195
+ flags: fragment.flags,
4196
+ msgId: fragment.msgId,
4197
+ fragIdx: fragment.fragIdx,
4198
+ fragCount: fragment.fragCount,
4199
+ payload: fragment.payload
4200
+ ) else {
4201
+ continue
4202
+ }
4203
+ totalWireBytes += packed.count
4204
+ let isFinalChunk = index == fragments.count - 1
4205
+ let chunkTrackingId = (requireAck && isFinalChunk) ? trackingId : "-1"
4206
+ queueSend(packed, id: chunkTrackingId)
4207
+
4208
+ if index < fragments.count - 1 {
4209
+ Thread.sleep(forTimeInterval: 0.05)
4210
+ }
4211
+ }
4212
+
4213
+ logWireMetrics(
4214
+ payloadBytes: payload.count,
4215
+ wireBytes: totalWireBytes,
4216
+ packetCount: fragments.count,
4217
+ protocolVersion: BleWireProtocol.protocolV2,
4218
+ direction: "phone_to_glasses"
4219
+ )
4220
+ Bridge.log("LIVE: Binary v2 queued \(fragments.count) fragments, wireBytes=\(totalWireBytes)")
4221
+ }
4222
+
3914
4223
  func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool = false, requireAck: Bool = true) {
3915
4224
  do {
3916
4225
  var json = jsonOriginal
@@ -3924,7 +4233,7 @@ class MentraLive: NSObject, SGCManager {
3924
4233
  globalMessageId += 1
3925
4234
  }
3926
4235
 
3927
- let jsonData = try JSONSerialization.data(withJSONObject: json)
4236
+ let jsonData = try JSONSerialization.data(withJSONObject: compactWireJson(json))
3928
4237
  if let jsonString = String(data: jsonData, encoding: .utf8) {
3929
4238
  let commandInfo = outgoingBleCommandTraceInfo(json)
3930
4239
  BleTraceLogger.logJson(
@@ -3934,6 +4243,17 @@ class MentraLive: NSObject, SGCManager {
3934
4243
  bytes: jsonData.count
3935
4244
  )
3936
4245
 
4246
+ if useBinaryWireProtocol, isNewVersion {
4247
+ sendJsonBinary(
4248
+ jsonString: jsonString,
4249
+ messageId: messageId,
4250
+ trackingId: trackingId,
4251
+ wakeUp: wakeUp,
4252
+ requireAck: requireAck
4253
+ )
4254
+ return
4255
+ }
4256
+
3937
4257
  // First check if the message needs chunking
3938
4258
  // Create a test C-wrapped version to check size
3939
4259
  var testWrapper: [String: Any] = [K900ProtocolUtils.FIELD_C: jsonString]
@@ -4687,6 +5007,14 @@ class MentraLive: NSObject, SGCManager {
4687
5007
  // Stop all timers
4688
5008
  stopAllTimers()
4689
5009
  incomingChunkReassembler.clear()
5010
+ peerWireProtocolVersion = 0
5011
+ useBinaryWireProtocol = false
5012
+ wireHandshakeQueued = false
5013
+ peerK900Le = false
5014
+ peerWireCapsBinary = false
5015
+ peerFilePayloadV2 = false
5016
+ BleJsonCompact.resetSession()
5017
+ closeL2capFileChannel()
4690
5018
 
4691
5019
  // Disconnect BLE
4692
5020
  if let peripheral = connectedPeripheral {
@@ -4745,12 +5073,72 @@ extension MentraLive {
4745
5073
  return result
4746
5074
  }
4747
5075
 
5076
+ /**
5077
+ * Heuristically detect the K900 STRING length field's endianness for a received frame. Mirrors
5078
+ * the firmware and Android codec: a length is plausible when it is non-zero, within the 512-byte
5079
+ * cap, and the full framed command fits inside the buffer; when both fit, prefer the one landing
5080
+ * on the trailing `$$`, then the smaller. Returns (length, isLittleEndian) or nil.
5081
+ */
5082
+ private func k900DetectStringLength(_ bytes: [UInt8]) -> (length: Int, isLe: Bool)? {
5083
+ guard bytes.count >= 7 else { return nil }
5084
+ let leLen = Int(bytes[3]) | (Int(bytes[4]) << 8)
5085
+ let beLen = (Int(bytes[3]) << 8) | Int(bytes[4])
5086
+
5087
+ let leOk = leLen > 0 && leLen <= 512 && leLen + 7 <= bytes.count
5088
+ let beOk = beLen > 0 && beLen <= 512 && beLen + 7 <= bytes.count
5089
+
5090
+ func endsWithEndCode(_ end: Int) -> Bool {
5091
+ end + 1 < bytes.count && bytes[end] == 0x24 && bytes[end + 1] == 0x24
5092
+ }
5093
+
5094
+ if leOk, beOk {
5095
+ if endsWithEndCode(5 + leLen) { return (leLen, true) }
5096
+ if endsWithEndCode(5 + beLen) { return (beLen, false) }
5097
+ return leLen <= beLen ? (leLen, true) : (beLen, false)
5098
+ }
5099
+ if leOk { return (leLen, true) }
5100
+ if beOk { return (beLen, false) }
5101
+ return nil
5102
+ }
5103
+
5104
+ /**
5105
+ * Parse a `wire_caps` object from a version_info/glasses_ready message and update the negotiated
5106
+ * per-link endianness and binary support. Missing wire_caps leaves the legacy defaults (BE, no
5107
+ * binary) untouched so older glasses keep working.
5108
+ */
5109
+ private func parsePeerWireCaps(_ json: [String: Any]) {
5110
+ guard let caps = json["wire_caps"] as? [String: Any] else { return }
5111
+ if (caps["k900_le"] as? Bool) == true {
5112
+ if !peerK900Le {
5113
+ peerK900Le = true
5114
+ Bridge.log("LIVE: wire_caps negotiated k900 endian=LE")
5115
+ }
5116
+ }
5117
+ if caps.keys.contains("binary") {
5118
+ peerWireCapsBinary = (caps["binary"] as? Bool) == true
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
+ }
5134
+ }
5135
+
4748
5136
  /**
4749
5137
  * Pack raw byte data with K900 BES2700 protocol format for phone-to-device communication
4750
5138
  * Format: ## + command_type + length(2bytes) + data + $$
4751
5139
  * Uses little-endian byte order for length field
4752
5140
  */
4753
- private func packDataToK900(_ data: Data?, cmdType: UInt8) -> Data? {
5141
+ private func packDataToK900(_ data: Data?, cmdType: UInt8, littleEndian: Bool = false) -> Data? {
4754
5142
  guard let data else { return nil }
4755
5143
 
4756
5144
  let dataLength = data.count
@@ -4764,9 +5152,14 @@ extension MentraLive {
4764
5152
  // Command type
4765
5153
  result.append(cmdType)
4766
5154
 
4767
- // Length (2 bytes, little-endian for phone-to-device)
4768
- result.append(UInt8(dataLength & 0xFF)) // LSB first
4769
- result.append(UInt8((dataLength >> 8) & 0xFF)) // MSB second
5155
+ // Length (2 bytes, negotiated endianness)
5156
+ if littleEndian {
5157
+ result.append(UInt8(dataLength & 0xFF)) // LSB first
5158
+ result.append(UInt8((dataLength >> 8) & 0xFF)) // MSB second
5159
+ } else {
5160
+ result.append(UInt8((dataLength >> 8) & 0xFF)) // MSB first
5161
+ result.append(UInt8(dataLength & 0xFF)) // LSB second
5162
+ }
4770
5163
 
4771
5164
  // Copy the data
4772
5165
  result.append(data)
@@ -4796,9 +5189,13 @@ extension MentraLive {
4796
5189
  let jsonData = try JSONSerialization.data(withJSONObject: wrapper)
4797
5190
  guard let wrappedJson = String(data: jsonData, encoding: .utf8) else { return nil }
4798
5191
 
4799
- // Then pack with BES2700 protocol format using little-endian
5192
+ // Then pack with BES2700 protocol format using the negotiated endianness
4800
5193
  let jsonBytes = wrappedJson.data(using: .utf8)!
4801
- return packDataToK900(jsonBytes, cmdType: K900ProtocolUtils.CMD_TYPE_STRING)
5194
+ return packDataToK900(
5195
+ jsonBytes,
5196
+ cmdType: K900ProtocolUtils.CMD_TYPE_STRING,
5197
+ littleEndian: peerK900Le
5198
+ )
4802
5199
 
4803
5200
  } catch {
4804
5201
  Bridge.log("Error creating JSON wrapper for K900: \(error)")
@@ -5091,7 +5488,11 @@ extension MentraLive {
5091
5488
  }
5092
5489
  let commandData = try JSONSerialization.data(withJSONObject: payload)
5093
5490
  guard
5094
- let packet = packDataToK900(commandData, cmdType: K900ProtocolUtils.CMD_TYPE_STRING)
5491
+ let packet = packDataToK900(
5492
+ commandData,
5493
+ cmdType: K900ProtocolUtils.CMD_TYPE_STRING,
5494
+ littleEndian: peerK900Le
5495
+ )
5095
5496
  else {
5096
5497
  Bridge.log("LIVE: Failed to pack raw K900 command")
5097
5498
  return false