@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.
Files changed (83) hide show
  1. package/README.md +6 -2
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +239 -52
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +47 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +6 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +381 -97
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +16 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +293 -174
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  19. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  20. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  21. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +31 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  29. package/build/BluetoothSdk.types.d.ts +71 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/_internal.d.ts +2 -0
  33. package/build/_internal.d.ts.map +1 -1
  34. package/build/_internal.js +2 -0
  35. package/build/_internal.js.map +1 -1
  36. package/build/_private/BluetoothSdkModule.d.ts +27 -2
  37. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  38. package/build/_private/BluetoothSdkModule.js +20 -0
  39. package/build/_private/BluetoothSdkModule.js.map +1 -1
  40. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  41. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  42. package/build/_private/MentraLocalNetworkModule.js +3 -0
  43. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  44. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  45. package/build/_private/cameraRequestPayload.js +1 -0
  46. package/build/_private/cameraRequestPayload.js.map +1 -1
  47. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  48. package/build/_private/photoRequestPayload.js +2 -0
  49. package/build/_private/photoRequestPayload.js.map +1 -1
  50. package/build/index.d.ts +1 -1
  51. package/build/index.d.ts.map +1 -1
  52. package/build/index.js +9 -0
  53. package/build/index.js.map +1 -1
  54. package/expo-module.config.json +1 -0
  55. package/ios/BluetoothSdkModule.swift +72 -0
  56. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  57. package/ios/Source/Bridge.swift +72 -18
  58. package/ios/Source/DeviceManager.swift +52 -3
  59. package/ios/Source/DeviceStore.swift +6 -1
  60. package/ios/Source/MentraBluetoothSDK.swift +298 -21
  61. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  62. package/ios/Source/camera/CameraModels.swift +22 -3
  63. package/ios/Source/sgcs/G1.swift +2 -2
  64. package/ios/Source/sgcs/G2.swift +19 -7
  65. package/ios/Source/sgcs/Mach1.swift +1 -1
  66. package/ios/Source/sgcs/MentraLive.swift +456 -52
  67. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  68. package/ios/Source/sgcs/MentraNex.swift +1 -1
  69. package/ios/Source/sgcs/Nimo.swift +1 -1
  70. package/ios/Source/sgcs/SGCManager.swift +5 -2
  71. package/ios/Source/sgcs/Simulated.swift +1 -1
  72. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  73. package/ios/Source/streaming/StreamModels.swift +9 -0
  74. package/ios/Source/tts/TTSTools.swift +1 -1
  75. package/ios/Source/utils/G1Text.swift +30 -1
  76. package/package.json +1 -2
  77. package/src/BluetoothSdk.types.ts +75 -0
  78. package/src/_internal.ts +2 -0
  79. package/src/_private/BluetoothSdkModule.ts +68 -13
  80. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  81. package/src/_private/cameraRequestPayload.ts +1 -0
  82. package/src/_private/photoRequestPayload.ts +3 -3
  83. 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
- // 1.0: the source already went through a lossy AVIF pass on the glasses,
122
- // so this re-encode must not compound the loss. Phone CPU and upload
123
- // bandwidth are cheap relative to what was paid to get the bytes over BLE.
124
- guard var jpegData = image.jpegData(compressionQuality: 1.0) else {
128
+ // AVIF sources already went through a lossy pass on the glasses; re-encode
129
+ // at high-but-not-max quality. JPEG fast-path payloads upload as-is.
130
+ guard var jpegData = image.jpegData(compressionQuality: 0.9) else {
125
131
  throw NSError(
126
132
  domain: "BlePhotoUpload",
127
133
  code: -2,
@@ -148,6 +154,11 @@ class BlePhotoUploadService {
148
154
  )
149
155
  }
150
156
 
157
+ private static func isJpeg(_ data: Data) -> Bool {
158
+ let bytes = [UInt8](data.prefix(2))
159
+ return bytes.count >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8
160
+ }
161
+
151
162
  private static func describeContainer(_ data: Data) -> String {
152
163
  let bytes = [UInt8](data.prefix(12))
153
164
  if bytes.count >= 2, bytes[0] == 0xFF, bytes[1] == 0xD8 { return "jpeg" }
@@ -495,7 +506,7 @@ extension Data {
495
506
  }
496
507
  }
497
508
 
498
- private enum K900ProtocolUtils {
509
+ enum K900ProtocolUtils {
499
510
  // Protocol constants
500
511
  static let CMD_START_CODE: [UInt8] = [0x23, 0x23] // ##
501
512
  static let CMD_END_CODE: [UInt8] = [0x24, 0x24] // $$
@@ -515,7 +526,9 @@ private enum K900ProtocolUtils {
515
526
  static let CMD_TYPE_DATA: UInt8 = 0x35
516
527
 
517
528
  // File transfer constants
518
- static let FILE_PACK_SIZE = 400 // Max data size per packet
529
+ // Negotiated file protocol ceiling. Legacy/GATT transfers remain smaller; 800-byte frames are
530
+ // accepted only after file_payload_v2 negotiation and an open CoC channel.
531
+ static let FILE_PACK_SIZE = 800
519
532
  static let LENGTH_FILE_START = 2
520
533
  static let LENGTH_FILE_TYPE = 1
521
534
  static let LENGTH_FILE_PACKSIZE = 2
@@ -618,7 +631,7 @@ private enum K900ProtocolUtils {
618
631
  print(
619
632
  "K900ProtocolUtils: File packet checksum failed. Expected: \(String(format: "%02X", info.verifyCode)), Calculated: \(String(format: "%02X", calculatedVerify))"
620
633
  )
621
- } else {
634
+ } else if shouldLogFilePacket(info) {
622
635
  print(
623
636
  "K900ProtocolUtils: File packet extracted successfully: index=\(info.packIndex), size=\(info.packSize), fileName=\(info.fileName)"
624
637
  )
@@ -626,6 +639,14 @@ private enum K900ProtocolUtils {
626
639
 
627
640
  return info
628
641
  }
642
+
643
+ static func shouldLogFilePacket(_ info: FilePacketInfo) -> Bool {
644
+ let totalPackets =
645
+ (Int(info.fileSize) + FILE_PACK_SIZE - 1) / FILE_PACK_SIZE
646
+ return info.packIndex == 0
647
+ || info.packIndex % 32 == 0
648
+ || Int(info.packIndex) >= max(totalPackets - 1, 0)
649
+ }
629
650
  }
630
651
 
631
652
  private struct FileTransferSession {
@@ -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
- incomingChunkReassembler.clear()
1225
- peerWireProtocolVersion = 0
1226
- useBinaryWireProtocol = false
1227
- wireHandshakeQueued = false
1228
- peerK900Le = false
1229
- peerWireCapsBinary = false
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"] = "auto"
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("LIVE: PHOTO PIPELINE [5b/6] take_photo JSON ready bleImgId=\(bleImgId) transferMethod=auto")
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.processSendQueue(command)
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
- centralManager?.connect(peripheral, options: nil)
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
- syntheticStatus = "step_complete"
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
- let json: [String: Any] = ["type": "request_wifi_scan"]
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
- Bridge.updateWifiScanResults(networks, scanComplete: scanComplete)
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
- Bridge.log("LIVE: 📦 BLE incident log relay packet for: \(bleImgId)")
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
- Bridge.log("📦 BLE photo transfer packet for requestId: \(photoTransfer.requestId)")
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
- Bridge.log(
3486
- "LIVE: 📦 Packet \(packetInfo.packIndex) received successfully (BES will auto-ACK)"
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
- incomingChunkReassembler.clear()
4903
- peerWireProtocolVersion = 0
4904
- useBinaryWireProtocol = false
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": 118, "roi_position": 0]
5881
- let fov = settings["fov"] as? Int ?? 118
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