@mentra/bluetooth-sdk 0.1.18 → 0.1.19

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 (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
  4. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
  5. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  13. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  22. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
  23. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  28. package/build/BluetoothSdk.types.d.ts +12 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/types/index.d.ts +3 -0
  32. package/build/types/index.d.ts.map +1 -0
  33. package/build/types/index.js +2 -0
  34. package/build/types/index.js.map +1 -0
  35. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  36. package/ios/Source/DeviceManager.swift +134 -29
  37. package/ios/Source/DeviceStore.swift +0 -5
  38. package/ios/Source/MentraBluetoothSDK.swift +26 -23
  39. package/ios/Source/OtaManifest.swift +1 -1
  40. package/ios/Source/sgcs/G2.swift +518 -11
  41. package/ios/Source/sgcs/MentraLive.swift +308 -30
  42. package/ios/Source/sgcs/MentraNex.swift +416 -32
  43. package/ios/Source/sgcs/SGCManager.swift +155 -0
  44. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  45. package/ios/Source/status/DeviceStatus.swift +0 -8
  46. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  47. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  48. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  49. package/ios/Source/utils/MessageChunker.swift +73 -0
  50. package/package.json +14 -7
  51. package/src/BluetoothSdk.types.ts +13 -1
  52. package/src/types/index.ts +7 -0
@@ -118,7 +118,10 @@ class BlePhotoUploadService {
118
118
  "\(TAG): Decoded image to bitmap: \(Int(image.size.width))x\(Int(image.size.height))"
119
119
  )
120
120
 
121
- guard var jpegData = image.jpegData(compressionQuality: 0.9) else {
121
+ // 1.0: the source already went through a lossy AVIF pass on the glasses,
122
+ // so this re-encode must not compound the loss. Phone CPU and upload
123
+ // bandwidth are cheap relative to what was paid to get the bytes over BLE.
124
+ guard var jpegData = image.jpegData(compressionQuality: 1.0) else {
122
125
  throw NSError(
123
126
  domain: "BlePhotoUpload",
124
127
  code: -2,
@@ -497,6 +500,7 @@ private enum K900ProtocolUtils {
497
500
  static let CMD_START_CODE: [UInt8] = [0x23, 0x23] // ##
498
501
  static let CMD_END_CODE: [UInt8] = [0x24, 0x24] // $$
499
502
  static let CMD_TYPE_STRING: UInt8 = 0x30 // String/JSON type
503
+ static let CMD_TYPE_BINARY_MSG: UInt8 = 0x40
500
504
 
501
505
  // JSON Field constants
502
506
  static let FIELD_C = "C" // Command/Content field
@@ -1218,6 +1222,12 @@ class MentraLive: NSObject, SGCManager {
1218
1222
  // or stale lastBesOtaProgress on the next OTA).
1219
1223
  if state == ConnTypes.DISCONNECTED {
1220
1224
  incomingChunkReassembler.clear()
1225
+ peerWireProtocolVersion = 0
1226
+ useBinaryWireProtocol = false
1227
+ wireHandshakeQueued = false
1228
+ peerK900Le = false
1229
+ peerWireCapsBinary = false
1230
+ BleJsonCompact.resetSession()
1221
1231
  stopSignalStrengthPolling()
1222
1232
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
1223
1233
  DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
@@ -1380,7 +1390,7 @@ class MentraLive: NSObject, SGCManager {
1380
1390
  private var connectedPeripheral: CBPeripheral?
1381
1391
  private var txCharacteristic: CBCharacteristic?
1382
1392
  private var rxCharacteristic: CBCharacteristic?
1383
- private let bes2700MtuLimit = 256
1393
+ private let bes2700MtuLimit = 509
1384
1394
  private var currentMtu: Int = 23 // Default BLE MTU
1385
1395
 
1386
1396
  // State Tracking
@@ -1389,6 +1399,14 @@ class MentraLive: NSObject, SGCManager {
1389
1399
  private var isKilled = false
1390
1400
  private var reconnectAttempts = 0
1391
1401
  private var isNewVersion = false
1402
+ private var peerWireProtocolVersion = 0
1403
+ private var useBinaryWireProtocol = false
1404
+ private var wireHandshakeQueued = false
1405
+ // Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to legacy
1406
+ // big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le (or a
1407
+ // v2 binary handshake succeeds, which implies wire-v2 LE).
1408
+ private var peerK900Le = false
1409
+ private var peerWireCapsBinary = false
1392
1410
  private var globalMessageId = 0
1393
1411
  private var lastReceivedMessageId = 0
1394
1412
  private var bleWriteTraceSequence = 0
@@ -1513,7 +1531,7 @@ class MentraLive: NSObject, SGCManager {
1513
1531
  Bridge.log("Found already-connected peripheral: \(peripheral.name ?? "Unknown")")
1514
1532
  if let name = peripheral.name,
1515
1533
  name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
1516
- || name.hasPrefix("MENTRA_LIVE_BT")
1534
+ || name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
1517
1535
  {
1518
1536
  Bridge.log("Found already-connected peripheral: \(name)")
1519
1537
  discoveredPeripherals[name] = peripheral
@@ -2106,17 +2124,17 @@ class MentraLive: NSObject, SGCManager {
2106
2124
  return // Exit after processing file packet
2107
2125
  }
2108
2126
 
2109
- let payloadLength: Int
2127
+ if commandType == K900ProtocolUtils.CMD_TYPE_BINARY_MSG {
2128
+ processBinaryWireFrame(data)
2129
+ return
2130
+ }
2110
2131
 
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])
2132
+ // Auto-detect the length endianness so we parse both legacy big-endian and wire-v2
2133
+ // little-endian frames, and learn the glasses' endianness for future outbound frames.
2134
+ let detected = k900DetectStringLength(bytes)
2135
+ let payloadLength = detected?.length ?? (Int(bytes[3]) | (Int(bytes[4]) << 8))
2136
+ if let detected {
2137
+ peerK900Le = detected.isLe
2120
2138
  }
2121
2139
 
2122
2140
  // Bridge.log(
@@ -2150,7 +2168,11 @@ class MentraLive: NSObject, SGCManager {
2150
2168
  }
2151
2169
  }
2152
2170
 
2153
- private func processJsonObject(_ json: [String: Any]) {
2171
+ private func processJsonObject(_ incoming: [String: Any]) {
2172
+ guard let json = expandCompactWireJson(incoming) else {
2173
+ Bridge.log("LIVE: Rejected unsupported compact wire form")
2174
+ return
2175
+ }
2154
2176
  // Log ALL incoming JSON objects for debugging
2155
2177
  // Bridge.log("LIVE: DEBUG: processJsonObject: \(json)")
2156
2178
  BleTraceLogger.logJson(direction: "glasses_to_phone", layer: "sdk_ble_event", payload: json)
@@ -2199,12 +2221,16 @@ class MentraLive: NSObject, SGCManager {
2199
2221
 
2200
2222
  switch type {
2201
2223
  case "glasses_ready":
2224
+ parsePeerWireCaps(json)
2202
2225
  handleGlassesReady()
2203
2226
 
2204
2227
  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)
2228
+ // Percent only (the glasses send it as "percent"; the old "level" read never
2229
+ // matched). battery_status derives from hm_batv, which carries no charge bit —
2230
+ // older glasses fabricate `charging` from a voltage threshold. Charging state
2231
+ // comes exclusively from the PMU charg bit in the sr_hrt heartbeat.
2232
+ let level = json["percent"] as? Int ?? json["level"] as? Int ?? batteryLevel
2233
+ updateBatteryStatus(level: level, isCharging: charging)
2208
2234
 
2209
2235
  case "voice_activity_detection_status":
2210
2236
  let enabled = json["voiceActivityDetectionEnabled"] as? Bool
@@ -2219,6 +2245,17 @@ class MentraLive: NSObject, SGCManager {
2219
2245
  let connected = json["connected"] as? Bool ?? false
2220
2246
  let ssid = json["ssid"] as? String ?? ""
2221
2247
  let ip = json["local_ip"] as? String ?? ""
2248
+ // Provisioning failure reason (e.g. connect_timeout). Sticky: routine
2249
+ // error-less status updates (link-state debounce, request_wifi_status)
2250
+ // must not clear a failure nothing recovered from — only a newer error
2251
+ // or a successful connection overwrites it.
2252
+ let wifiError = json["error"] as? String ?? ""
2253
+ if !wifiError.isEmpty {
2254
+ Bridge.log("LIVE: 🌐 WiFi provisioning error from glasses: \(wifiError)")
2255
+ DeviceStore.shared.apply("glasses", "wifiError", wifiError)
2256
+ } else if connected {
2257
+ DeviceStore.shared.apply("glasses", "wifiError", "")
2258
+ }
2222
2259
  updateWifiStatus(connected: connected, ssid: ssid, ip: ip)
2223
2260
 
2224
2261
  case "hotspot_status_update":
@@ -2463,6 +2500,8 @@ class MentraLive: NSObject, SGCManager {
2463
2500
  if let buildNumber = fields["build_number"] as? String {
2464
2501
  isNewVersion = (Int(buildNumber) ?? 0) >= 5
2465
2502
  DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
2503
+ parsePeerWireCaps(json)
2504
+ maybeSendWireHandshake()
2466
2505
  }
2467
2506
  if let deviceModel = fields["device_model"] as? String {
2468
2507
  DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
@@ -2595,10 +2634,13 @@ class MentraLive: NSObject, SGCManager {
2595
2634
  if let bodyObj = json["B"] as? [String: Any] {
2596
2635
  let readyResponse = bodyObj["ready"] as? Int ?? 0
2597
2636
 
2598
- // Extract battery info from heartbeat
2637
+ // Extract battery info from heartbeat. charg is the PMU charging bit — the
2638
+ // only truthful charging source in the protocol. Old firmware omits it;
2639
+ // keep the last known state then instead of defaulting to not-charging.
2599
2640
  let percentage = bodyObj["pt"] as? Int ?? 0
2600
2641
  let voltage = bodyObj["vt"] as? Int ?? 0
2601
- let charging = (bodyObj["charg"] as? Int ?? 0) == 1
2642
+ let chargBit = bodyObj["charg"] as? Int
2643
+ let charging = chargBit != nil ? (chargBit == 1) : self.charging
2602
2644
 
2603
2645
  // SOC is still booting
2604
2646
  if readyResponse == 0 {
@@ -2646,12 +2688,15 @@ class MentraLive: NSObject, SGCManager {
2646
2688
  let percentage = body["pt"] as? Int
2647
2689
  {
2648
2690
  let voltageVolts = Double(voltage) / 1000.0
2649
- let isCharging = voltage > 4000
2650
2691
 
2651
2692
  Bridge.log(
2652
2693
  "🔋 K900 Battery Status - Voltage: \(voltageVolts)V, Level: \(percentage)%"
2653
2694
  )
2654
- updateBatteryStatus(level: percentage, isCharging: isCharging)
2695
+ // Percent only. sr_batv carries just voltage+percent; inferring charging
2696
+ // from voltage (>4.0V) reads "not charging" for most of a genuinely-charging
2697
+ // pack's range. Charging state comes exclusively from the PMU charg bit in
2698
+ // the sr_hrt heartbeat.
2699
+ updateBatteryStatus(level: percentage, isCharging: charging)
2655
2700
  }
2656
2701
 
2657
2702
  case "sr_getvol":
@@ -3057,6 +3102,7 @@ class MentraLive: NSObject, SGCManager {
3057
3102
  DeviceStore.shared.apply("glasses", "firmwareVersion", firmwareVersion)
3058
3103
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
3059
3104
  isNewVersion = (Int(buildNumber) ?? 0) >= 5
3105
+ maybeSendWireHandshake()
3060
3106
  DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
3061
3107
  DeviceStore.shared.apply("glasses", "androidVersion", androidVersion)
3062
3108
 
@@ -3566,7 +3612,7 @@ class MentraLive: NSObject, SGCManager {
3566
3612
  let basePath = components.path.hasSuffix("/")
3567
3613
  ? String(components.path.dropLast())
3568
3614
  : components.path
3569
- components.path = basePath + "/api/incidents/\(relay.incidentId)/logs"
3615
+ components.path = basePath + "/api/client/reports/\(relay.incidentId)/artifacts"
3570
3616
  guard let url = components.url else {
3571
3617
  sendTransferCompleteConfirmation(fileName: fileName, success: false)
3572
3618
  if let existing = bleIncidentLogRelays[relay.fileBaseKey] {
@@ -3911,6 +3957,162 @@ class MentraLive: NSObject, SGCManager {
3911
3957
  }
3912
3958
  }
3913
3959
 
3960
+ private func maybeSendWireHandshake() {
3961
+ // Only attempt the v2 binary handshake once the glasses have advertised binary support via
3962
+ // wire_caps. Older builds that report new version but lack wire_caps stay on the legacy path.
3963
+ guard isNewVersion,
3964
+ peerWireCapsBinary,
3965
+ !wireHandshakeQueued,
3966
+ peerWireProtocolVersion < BleWireProtocol.protocolV2
3967
+ else { return }
3968
+ sendWireHandshake()
3969
+ }
3970
+
3971
+ private func sendWireHandshake() {
3972
+ guard isNewVersion else { return }
3973
+ var flags = BleWireProtocol.flagHandshake
3974
+ flags |= BleWireProtocol.flagFirstFrag
3975
+ flags |= BleWireProtocol.flagLastFrag
3976
+ let payload = Data(BleWireProtocol.handshakePayloadV2.utf8)
3977
+ guard let packed = BleWireProtocol.packBinaryFragment(
3978
+ flags: flags,
3979
+ msgId: 0,
3980
+ fragIdx: 0,
3981
+ fragCount: 1,
3982
+ payload: payload
3983
+ ) else {
3984
+ return
3985
+ }
3986
+ Bridge.log("LIVE: Sending BLE wire v2 handshake")
3987
+ wireHandshakeQueued = true
3988
+ queueSend(packed, id: "-1")
3989
+ }
3990
+
3991
+ private func activateBinaryWireV2Session(logMessage: String) {
3992
+ peerWireProtocolVersion = BleWireProtocol.protocolV2
3993
+ useBinaryWireProtocol = true
3994
+ wireHandshakeQueued = false
3995
+ peerK900Le = true
3996
+ BleJsonCompact.markSessionConnected(epochMs: Int64(Date().timeIntervalSince1970 * 1000))
3997
+ Bridge.log(logMessage)
3998
+ }
3999
+
4000
+ private func handlePeerWireHandshake() {
4001
+ activateBinaryWireV2Session(logMessage: "LIVE: Peer confirmed BLE wire protocol v2")
4002
+ }
4003
+
4004
+ private func processBinaryWireFrame(_ data: Data) {
4005
+ guard let info = BleWireProtocol.extractBinaryFragmentInfo(data) else {
4006
+ Bridge.log("LIVE: Failed to parse binary wire frame")
4007
+ return
4008
+ }
4009
+
4010
+ if BleWireProtocol.isHandshakeV2(info) {
4011
+ handlePeerWireHandshake()
4012
+ return
4013
+ }
4014
+
4015
+ if !useBinaryWireProtocol, isNewVersion {
4016
+ activateBinaryWireV2Session(logMessage: "LIVE: Auto-enabled BLE wire v2 from incoming binary frame")
4017
+ }
4018
+
4019
+ guard let reassembled = incomingChunkReassembler.addBinaryFragment(
4020
+ msgId: info.msgId,
4021
+ fragIdx: Int(info.fragIdx),
4022
+ fragCount: Int(info.fragCount),
4023
+ data: info.payload
4024
+ ) else {
4025
+ return
4026
+ }
4027
+
4028
+ guard let jsonString = String(data: reassembled, encoding: .utf8) else {
4029
+ Bridge.log("LIVE: Failed to decode reassembled binary wire payload")
4030
+ return
4031
+ }
4032
+
4033
+ logWireMetrics(
4034
+ payloadBytes: reassembled.count,
4035
+ wireBytes: data.count,
4036
+ packetCount: Int(info.fragCount),
4037
+ protocolVersion: BleWireProtocol.protocolV2,
4038
+ direction: "glasses_to_phone"
4039
+ )
4040
+ processJsonMessage(jsonString)
4041
+ }
4042
+
4043
+ private func compactWireJson(_ json: [String: Any]) -> [String: Any] {
4044
+ guard useBinaryWireProtocol, isNewVersion else { return json }
4045
+ return BleJsonCompact.encode(json)
4046
+ }
4047
+
4048
+ private func expandCompactWireJson(_ json: [String: Any]) -> [String: Any]? {
4049
+ guard useBinaryWireProtocol, isNewVersion else { return json }
4050
+ return BleJsonCompact.decodeIfSupported(json)
4051
+ }
4052
+
4053
+ private func logWireMetrics(
4054
+ payloadBytes: Int,
4055
+ wireBytes: Int,
4056
+ packetCount: Int,
4057
+ protocolVersion: Int,
4058
+ direction: String
4059
+ ) {
4060
+ Bridge.log(
4061
+ "BLE_TRACE direction=\(direction) proto=v\(protocolVersion) payload=\(payloadBytes) wire=\(wireBytes) packets=\(packetCount)"
4062
+ )
4063
+ }
4064
+
4065
+ private func sendJsonBinary(
4066
+ jsonString: String,
4067
+ messageId: Int64,
4068
+ trackingId: String,
4069
+ wakeUp: Bool,
4070
+ requireAck: Bool
4071
+ ) {
4072
+ let payload = Data(jsonString.utf8)
4073
+ let msgId = UInt16(truncatingIfNeeded: messageId)
4074
+ let fragments = MessageChunker.createBinaryFragments(
4075
+ payload: payload,
4076
+ msgId: msgId,
4077
+ wakeUp: wakeUp,
4078
+ ackRequested: requireAck && messageId >= 0
4079
+ )
4080
+ guard !fragments.isEmpty else {
4081
+ Bridge.log("LIVE: Failed to create binary wire fragments")
4082
+ return
4083
+ }
4084
+
4085
+ var totalWireBytes = 0
4086
+ for (index, fragment) in fragments.enumerated() {
4087
+ guard let packed = BleWireProtocol.packBinaryFragment(
4088
+ flags: fragment.flags,
4089
+ msgId: fragment.msgId,
4090
+ fragIdx: fragment.fragIdx,
4091
+ fragCount: fragment.fragCount,
4092
+ payload: fragment.payload
4093
+ ) else {
4094
+ continue
4095
+ }
4096
+ totalWireBytes += packed.count
4097
+ let isFinalChunk = index == fragments.count - 1
4098
+ let chunkTrackingId = (requireAck && isFinalChunk) ? trackingId : "-1"
4099
+ queueSend(packed, id: chunkTrackingId)
4100
+
4101
+ if index < fragments.count - 1 {
4102
+ Thread.sleep(forTimeInterval: 0.05)
4103
+ }
4104
+ }
4105
+
4106
+ logWireMetrics(
4107
+ payloadBytes: payload.count,
4108
+ wireBytes: totalWireBytes,
4109
+ packetCount: fragments.count,
4110
+ protocolVersion: BleWireProtocol.protocolV2,
4111
+ direction: "phone_to_glasses"
4112
+ )
4113
+ Bridge.log("LIVE: Binary v2 queued \(fragments.count) fragments, wireBytes=\(totalWireBytes)")
4114
+ }
4115
+
3914
4116
  func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool = false, requireAck: Bool = true) {
3915
4117
  do {
3916
4118
  var json = jsonOriginal
@@ -3924,7 +4126,7 @@ class MentraLive: NSObject, SGCManager {
3924
4126
  globalMessageId += 1
3925
4127
  }
3926
4128
 
3927
- let jsonData = try JSONSerialization.data(withJSONObject: json)
4129
+ let jsonData = try JSONSerialization.data(withJSONObject: compactWireJson(json))
3928
4130
  if let jsonString = String(data: jsonData, encoding: .utf8) {
3929
4131
  let commandInfo = outgoingBleCommandTraceInfo(json)
3930
4132
  BleTraceLogger.logJson(
@@ -3934,6 +4136,17 @@ class MentraLive: NSObject, SGCManager {
3934
4136
  bytes: jsonData.count
3935
4137
  )
3936
4138
 
4139
+ if useBinaryWireProtocol, isNewVersion {
4140
+ sendJsonBinary(
4141
+ jsonString: jsonString,
4142
+ messageId: messageId,
4143
+ trackingId: trackingId,
4144
+ wakeUp: wakeUp,
4145
+ requireAck: requireAck
4146
+ )
4147
+ return
4148
+ }
4149
+
3937
4150
  // First check if the message needs chunking
3938
4151
  // Create a test C-wrapped version to check size
3939
4152
  var testWrapper: [String: Any] = [K900ProtocolUtils.FIELD_C: jsonString]
@@ -4687,6 +4900,12 @@ class MentraLive: NSObject, SGCManager {
4687
4900
  // Stop all timers
4688
4901
  stopAllTimers()
4689
4902
  incomingChunkReassembler.clear()
4903
+ peerWireProtocolVersion = 0
4904
+ useBinaryWireProtocol = false
4905
+ wireHandshakeQueued = false
4906
+ peerK900Le = false
4907
+ peerWireCapsBinary = false
4908
+ BleJsonCompact.resetSession()
4690
4909
 
4691
4910
  // Disconnect BLE
4692
4911
  if let peripheral = connectedPeripheral {
@@ -4745,12 +4964,58 @@ extension MentraLive {
4745
4964
  return result
4746
4965
  }
4747
4966
 
4967
+ /**
4968
+ * Heuristically detect the K900 STRING length field's endianness for a received frame. Mirrors
4969
+ * the firmware and Android codec: a length is plausible when it is non-zero, within the 512-byte
4970
+ * cap, and the full framed command fits inside the buffer; when both fit, prefer the one landing
4971
+ * on the trailing `$$`, then the smaller. Returns (length, isLittleEndian) or nil.
4972
+ */
4973
+ private func k900DetectStringLength(_ bytes: [UInt8]) -> (length: Int, isLe: Bool)? {
4974
+ guard bytes.count >= 7 else { return nil }
4975
+ let leLen = Int(bytes[3]) | (Int(bytes[4]) << 8)
4976
+ let beLen = (Int(bytes[3]) << 8) | Int(bytes[4])
4977
+
4978
+ let leOk = leLen > 0 && leLen <= 512 && leLen + 7 <= bytes.count
4979
+ let beOk = beLen > 0 && beLen <= 512 && beLen + 7 <= bytes.count
4980
+
4981
+ func endsWithEndCode(_ end: Int) -> Bool {
4982
+ end + 1 < bytes.count && bytes[end] == 0x24 && bytes[end + 1] == 0x24
4983
+ }
4984
+
4985
+ if leOk, beOk {
4986
+ if endsWithEndCode(5 + leLen) { return (leLen, true) }
4987
+ if endsWithEndCode(5 + beLen) { return (beLen, false) }
4988
+ return leLen <= beLen ? (leLen, true) : (beLen, false)
4989
+ }
4990
+ if leOk { return (leLen, true) }
4991
+ if beOk { return (beLen, false) }
4992
+ return nil
4993
+ }
4994
+
4995
+ /**
4996
+ * Parse a `wire_caps` object from a version_info/glasses_ready message and update the negotiated
4997
+ * per-link endianness and binary support. Missing wire_caps leaves the legacy defaults (BE, no
4998
+ * binary) untouched so older glasses keep working.
4999
+ */
5000
+ private func parsePeerWireCaps(_ json: [String: Any]) {
5001
+ guard let caps = json["wire_caps"] as? [String: Any] else { return }
5002
+ if (caps["k900_le"] as? Bool) == true {
5003
+ if !peerK900Le {
5004
+ peerK900Le = true
5005
+ Bridge.log("LIVE: wire_caps negotiated k900 endian=LE")
5006
+ }
5007
+ }
5008
+ if caps.keys.contains("binary") {
5009
+ peerWireCapsBinary = (caps["binary"] as? Bool) == true
5010
+ }
5011
+ }
5012
+
4748
5013
  /**
4749
5014
  * Pack raw byte data with K900 BES2700 protocol format for phone-to-device communication
4750
5015
  * Format: ## + command_type + length(2bytes) + data + $$
4751
5016
  * Uses little-endian byte order for length field
4752
5017
  */
4753
- private func packDataToK900(_ data: Data?, cmdType: UInt8) -> Data? {
5018
+ private func packDataToK900(_ data: Data?, cmdType: UInt8, littleEndian: Bool = false) -> Data? {
4754
5019
  guard let data else { return nil }
4755
5020
 
4756
5021
  let dataLength = data.count
@@ -4764,9 +5029,14 @@ extension MentraLive {
4764
5029
  // Command type
4765
5030
  result.append(cmdType)
4766
5031
 
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
5032
+ // Length (2 bytes, negotiated endianness)
5033
+ if littleEndian {
5034
+ result.append(UInt8(dataLength & 0xFF)) // LSB first
5035
+ result.append(UInt8((dataLength >> 8) & 0xFF)) // MSB second
5036
+ } else {
5037
+ result.append(UInt8((dataLength >> 8) & 0xFF)) // MSB first
5038
+ result.append(UInt8(dataLength & 0xFF)) // LSB second
5039
+ }
4770
5040
 
4771
5041
  // Copy the data
4772
5042
  result.append(data)
@@ -4796,9 +5066,13 @@ extension MentraLive {
4796
5066
  let jsonData = try JSONSerialization.data(withJSONObject: wrapper)
4797
5067
  guard let wrappedJson = String(data: jsonData, encoding: .utf8) else { return nil }
4798
5068
 
4799
- // Then pack with BES2700 protocol format using little-endian
5069
+ // Then pack with BES2700 protocol format using the negotiated endianness
4800
5070
  let jsonBytes = wrappedJson.data(using: .utf8)!
4801
- return packDataToK900(jsonBytes, cmdType: K900ProtocolUtils.CMD_TYPE_STRING)
5071
+ return packDataToK900(
5072
+ jsonBytes,
5073
+ cmdType: K900ProtocolUtils.CMD_TYPE_STRING,
5074
+ littleEndian: peerK900Le
5075
+ )
4802
5076
 
4803
5077
  } catch {
4804
5078
  Bridge.log("Error creating JSON wrapper for K900: \(error)")
@@ -5091,7 +5365,11 @@ extension MentraLive {
5091
5365
  }
5092
5366
  let commandData = try JSONSerialization.data(withJSONObject: payload)
5093
5367
  guard
5094
- let packet = packDataToK900(commandData, cmdType: K900ProtocolUtils.CMD_TYPE_STRING)
5368
+ let packet = packDataToK900(
5369
+ commandData,
5370
+ cmdType: K900ProtocolUtils.CMD_TYPE_STRING,
5371
+ littleEndian: peerK900Le
5372
+ )
5095
5373
  else {
5096
5374
  Bridge.log("LIVE: Failed to pack raw K900 command")
5097
5375
  return false