@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.1

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 (114) hide show
  1. package/README.md +27 -12
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  13. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
  14. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  31. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  32. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
  33. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  34. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  37. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  46. package/build/BluetoothSdk.types.d.ts +100 -22
  47. package/build/BluetoothSdk.types.d.ts.map +1 -1
  48. package/build/BluetoothSdk.types.js +2 -1
  49. package/build/BluetoothSdk.types.js.map +1 -1
  50. package/build/_internal.d.ts +3 -0
  51. package/build/_internal.d.ts.map +1 -1
  52. package/build/_internal.js +3 -0
  53. package/build/_internal.js.map +1 -1
  54. package/build/_private/BluetoothSdkModule.d.ts +31 -4
  55. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  56. package/build/_private/BluetoothSdkModule.js +26 -3
  57. package/build/_private/BluetoothSdkModule.js.map +1 -1
  58. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  59. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  60. package/build/_private/MentraLocalNetworkModule.js +3 -0
  61. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  62. package/build/_private/cameraRequestPayload.d.ts +1 -1
  63. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  64. package/build/_private/cameraRequestPayload.js +7 -0
  65. package/build/_private/cameraRequestPayload.js.map +1 -1
  66. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  67. package/build/_private/photoRequestPayload.js +10 -0
  68. package/build/_private/photoRequestPayload.js.map +1 -1
  69. package/build/_private/sdkOtaManifest.d.ts +20 -0
  70. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  71. package/build/_private/sdkOtaManifest.js +28 -0
  72. package/build/_private/sdkOtaManifest.js.map +1 -0
  73. package/build/index.d.ts +1 -1
  74. package/build/index.d.ts.map +1 -1
  75. package/build/index.js +11 -1
  76. package/build/index.js.map +1 -1
  77. package/expo-module.config.json +1 -0
  78. package/ios/BluetoothSdkModule.swift +113 -2
  79. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  80. package/ios/Source/Bridge.swift +87 -21
  81. package/ios/Source/DeviceManager.swift +118 -20
  82. package/ios/Source/DeviceStore.swift +4 -0
  83. package/ios/Source/MentraBluetoothSDK.swift +326 -33
  84. package/ios/Source/OtaManifest.swift +47 -4
  85. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  86. package/ios/Source/camera/CameraModels.swift +26 -6
  87. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  88. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  89. package/ios/Source/sgcs/Ar99.swift +2451 -0
  90. package/ios/Source/sgcs/G1.swift +1 -1
  91. package/ios/Source/sgcs/G2.swift +19 -7
  92. package/ios/Source/sgcs/Mach1.swift +1 -1
  93. package/ios/Source/sgcs/MentraLive.swift +478 -61
  94. package/ios/Source/sgcs/MentraNex.swift +1 -1
  95. package/ios/Source/sgcs/Nimo.swift +1 -1
  96. package/ios/Source/sgcs/SGCManager.swift +13 -2
  97. package/ios/Source/sgcs/Simulated.swift +1 -1
  98. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  99. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  100. package/ios/Source/streaming/StreamModels.swift +68 -2
  101. package/ios/Source/tts/TTSTools.swift +1 -1
  102. package/ios/Source/types/DeviceModels.swift +13 -0
  103. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  104. package/ios/Source/utils/Constants.swift +2 -0
  105. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  106. package/package.json +1 -2
  107. package/src/BluetoothSdk.types.ts +106 -24
  108. package/src/_internal.ts +3 -0
  109. package/src/_private/BluetoothSdkModule.ts +83 -18
  110. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  111. package/src/_private/cameraRequestPayload.ts +9 -2
  112. package/src/_private/photoRequestPayload.ts +13 -4
  113. package/src/_private/sdkOtaManifest.ts +31 -0
  114. package/src/index.ts +13 -1
@@ -163,7 +163,7 @@ class BlePhotoUploadService {
163
163
  let bytes = [UInt8](data.prefix(12))
164
164
  if bytes.count >= 2, bytes[0] == 0xFF, bytes[1] == 0xD8 { return "jpeg" }
165
165
  if bytes.count >= 12, bytes[4] == 0x66, bytes[5] == 0x74, bytes[6] == 0x79, bytes[7] == 0x70 {
166
- let brand = String(bytes: bytes[8..<12], encoding: .ascii) ?? "?"
166
+ let brand = String(bytes: bytes[8 ..< 12], encoding: .ascii) ?? "?"
167
167
  return "iso_bmff/ftyp=\(brand)"
168
168
  }
169
169
  return "unknown"
@@ -173,8 +173,8 @@ class BlePhotoUploadService {
173
173
  let marker: [UInt8] = [0x45, 0x78, 0x69, 0x66, 0, 0]
174
174
  let bytes = [UInt8](data)
175
175
  guard bytes.count >= marker.count else { return false }
176
- for i in 0...(bytes.count - marker.count) {
177
- if Array(bytes[i..<(i + marker.count)]) == marker { return true }
176
+ for i in 0 ... (bytes.count - marker.count) {
177
+ if Array(bytes[i ..< (i + marker.count)]) == marker { return true }
178
178
  }
179
179
  return false
180
180
  }
@@ -182,7 +182,7 @@ class BlePhotoUploadService {
182
182
  private static func readImuJsonFromImageData(_ imageData: Data) -> String? {
183
183
  // Primary: ImageIO EXIF (works for JPEG and well-formed AVIF)
184
184
  if let source = CGImageSourceCreateWithData(imageData as CFData, nil),
185
- let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any]
185
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any]
186
186
  {
187
187
  let exif = properties[kCGImagePropertyExifDictionary as String] as? [String: Any]
188
188
  let userComment = exif?[kCGImagePropertyExifUserComment as String] as? String
@@ -223,8 +223,8 @@ class BlePhotoUploadService {
223
223
  guard magic == 0x002A else { searchFrom = exifOff + 1; continue }
224
224
  let ifd0Off = Int(readU32(bytes, at: tiff + 4, le: littleEndian))
225
225
  if let result = readTagFromIfd(
226
- bytes, tiff: tiff, ifdOff: tiff + ifd0Off, tag: 0x9286, le: littleEndian)
227
- {
226
+ bytes, tiff: tiff, ifdOff: tiff + ifd0Off, tag: 0x9286, le: littleEndian
227
+ ) {
228
228
  return result
229
229
  }
230
230
  searchFrom = exifOff + 1
@@ -238,7 +238,7 @@ class BlePhotoUploadService {
238
238
  guard ifdOff + 2 <= bytes.count else { return nil }
239
239
  let count = Int(readU16(bytes, at: ifdOff, le: le))
240
240
  var off = ifdOff + 2
241
- for _ in 0..<count {
241
+ for _ in 0 ..< count {
242
242
  guard off + 12 <= bytes.count else { break }
243
243
  let entryTag = readU16(bytes, at: off, le: le)
244
244
  let type = readU16(bytes, at: off + 2, le: le)
@@ -255,7 +255,7 @@ class BlePhotoUploadService {
255
255
  let startOff = (tag == 0x9286 && byteLen > 8) ? valueOff + 8 : valueOff
256
256
  let len = (tag == 0x9286 && byteLen > 8) ? byteLen - 8 : byteLen
257
257
  guard startOff + len <= bytes.count else { return nil }
258
- return String(bytes: Array(bytes[startOff..<startOff + len]), encoding: .utf8)?
258
+ return String(bytes: Array(bytes[startOff ..< startOff + len]), encoding: .utf8)?
259
259
  .trimmingCharacters(in: .init(charactersIn: "\0"))
260
260
  }
261
261
  // Follow Exif IFD pointer
@@ -272,8 +272,8 @@ class BlePhotoUploadService {
272
272
 
273
273
  private static func findBytes(_ needle: [UInt8], in haystack: [UInt8], from: Int) -> Int? {
274
274
  guard haystack.count >= needle.count else { return nil }
275
- for i in from...(haystack.count - needle.count) {
276
- if Array(haystack[i..<i + needle.count]) == needle { return i }
275
+ for i in from ... (haystack.count - needle.count) {
276
+ if Array(haystack[i ..< i + needle.count]) == needle { return i }
277
277
  }
278
278
  return nil
279
279
  }
@@ -298,8 +298,8 @@ class BlePhotoUploadService {
298
298
 
299
299
  private static func writeImuJsonToJpegData(_ jpegData: Data, imuJson: String) throws -> Data {
300
300
  guard let source = CGImageSourceCreateWithData(jpegData as CFData, nil),
301
- let imageType = CGImageSourceGetType(source),
302
- let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil)
301
+ let imageType = CGImageSourceGetType(source),
302
+ let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil)
303
303
  else {
304
304
  throw PhotoUploadError.decodingFailed
305
305
  }
@@ -373,8 +373,8 @@ class BlePhotoUploadService {
373
373
 
374
374
  // Find last Exif marker
375
375
  var lastExif = -1
376
- for i in 0...(bytes.count - marker.count) {
377
- if Array(bytes[i..<i + marker.count]) == marker { lastExif = i }
376
+ for i in 0 ... (bytes.count - marker.count) {
377
+ if Array(bytes[i ..< i + marker.count]) == marker { lastExif = i }
378
378
  }
379
379
  guard lastExif >= 0 else { return data }
380
380
 
@@ -383,7 +383,7 @@ class BlePhotoUploadService {
383
383
  while off + 8 <= bytes.count {
384
384
  let boxSize = Int(readU32BE(bytes, at: off))
385
385
  guard boxSize >= 8, off + boxSize <= bytes.count else { break }
386
- let boxType = String(bytes: Array(bytes[off + 4..<off + 8]), encoding: .ascii) ?? ""
386
+ let boxType = String(bytes: Array(bytes[off + 4 ..< off + 8]), encoding: .ascii) ?? ""
387
387
  if boxType == "mdat" {
388
388
  let payloadStart = off + 8
389
389
  let payloadEnd = off + boxSize
@@ -393,7 +393,7 @@ class BlePhotoUploadService {
393
393
 
394
394
  var result = Data()
395
395
  // Everything up to mdat header
396
- result.append(contentsOf: bytes[0..<off])
396
+ result.append(contentsOf: bytes[0 ..< off])
397
397
  // New mdat box header with updated size
398
398
  let newMdatSize = UInt32(8 + newPayloadLen)
399
399
  result.append(UInt8((newMdatSize >> 24) & 0xFF))
@@ -401,7 +401,7 @@ class BlePhotoUploadService {
401
401
  result.append(UInt8((newMdatSize >> 8) & 0xFF))
402
402
  result.append(UInt8(newMdatSize & 0xFF))
403
403
  result.append(contentsOf: [0x6D, 0x64, 0x61, 0x74]) // "mdat"
404
- result.append(contentsOf: bytes[payloadStart..<payloadStart + newPayloadLen])
404
+ result.append(contentsOf: bytes[payloadStart ..< payloadStart + newPayloadLen])
405
405
  return result
406
406
  }
407
407
  off += boxSize
@@ -824,7 +824,7 @@ private final class BleIncidentLogRelayEntry {
824
824
  // MARK: - CBCentralManagerDelegate
825
825
 
826
826
  extension MentraLive: CBCentralManagerDelegate {
827
- // CoreBluetooth delivers these callbacks on `bluetoothQueue`; hop back before touching MainActor state.
827
+ /// CoreBluetooth delivers these callbacks on `bluetoothQueue`; hop back before touching MainActor state.
828
828
  nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
829
829
  let state = central.state
830
830
  DispatchQueue.main.async { [weak self] in
@@ -926,6 +926,18 @@ extension MentraLive: CBCentralManagerDelegate {
926
926
  }
927
927
  }
928
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
+
929
941
  nonisolated func centralManager(
930
942
  _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?
931
943
  ) {
@@ -938,6 +950,8 @@ extension MentraLive: CBCentralManagerDelegate {
938
950
  self.connectedPeripheral = nil
939
951
  self.fullyBooted = false
940
952
  self.connected = false
953
+ self.glassesSessionId = nil // Fresh BLE session starts with no sid known
954
+ self.readinessCompletedThisBleSession = false
941
955
  self.updateConnectionState(ConnTypes.DISCONNECTED)
942
956
  self.rgbLedAuthorityClaimed = false
943
957
 
@@ -1095,6 +1109,10 @@ extension MentraLive: CBPeripheralDelegate {
1095
1109
  Bridge.log("LIVE: 🎤 Enabled LC3 audio notifications")
1096
1110
  }
1097
1111
 
1112
+ // Authorization may complete before or after service discovery.
1113
+ // This covers the former; the central-manager callback covers the latter.
1114
+ self.enableAncsRelayIfAuthorized()
1115
+
1098
1116
  // Start readiness check loop
1099
1117
  self.startSignalStrengthPolling()
1100
1118
  self.startReadinessCheckLoop()
@@ -1213,6 +1231,7 @@ extension MentraLive: CBPeripheralDelegate {
1213
1231
 
1214
1232
  if uuid == self.RX_CHAR_UUID, isNotifying {
1215
1233
  Bridge.log("LIVE: 🔔 Ready to receive data via notifications")
1234
+ self.enableAncsRelayIfAuthorized()
1216
1235
  }
1217
1236
  }
1218
1237
  }
@@ -1256,25 +1275,33 @@ class MentraLive: NSObject, SGCManager {
1256
1275
  // to avoid overloading the MCU. Set to false to allow simultaneous A2DP + LC3 mic.
1257
1276
  private let BLOCK_AUDIO_DUPLEX = false
1258
1277
  private static let voiceActivityDetectionSwitchType = 8
1278
+ private static let loudnessGateSwitchType = 10
1259
1279
 
1260
1280
  var connectionState: String = ConnTypes.DISCONNECTED
1261
1281
 
1262
1282
  /// Mirrors Android `updateConnectionState` — RN home reads `glasses.connectionState` for reconnecting UI.
1263
1283
  private func updateConnectionState(_ state: String) {
1284
+ if state == ConnTypes.DISCONNECTED {
1285
+ // A manufacturing serial is session-bound. Clear it on disconnect so a previous
1286
+ // pair's serial cannot be associated with the next connection. Connect must NOT
1287
+ // clear it: DeviceManager.disconnect already wipes it before any new connection, and
1288
+ // clearing on CONNECTED would wipe a still-valid serial mid-session when a same-link
1289
+ // glasses_ready (e.g. ASG restart) re-publishes CONNECTED (iOS has no equal-state
1290
+ // early return, unlike Android).
1291
+ DeviceStore.shared.apply("glasses", "serialNumber", "")
1292
+ }
1264
1293
  connectionState = state
1265
1294
  DeviceStore.shared.apply("glasses", "connectionState", state)
1266
1295
  // Drop OTA caches when fully disconnected — avoids leaking session/step state from
1267
1296
  // a previous pairing into the next one (would otherwise surface as wrong overall_percent
1268
1297
  // or stale lastBesOtaProgress on the next OTA).
1269
1298
  if state == ConnTypes.DISCONNECTED {
1270
- incomingChunkReassembler.clear()
1271
- peerWireProtocolVersion = 0
1272
- useBinaryWireProtocol = false
1273
- wireHandshakeQueued = false
1274
- peerK900Le = false
1275
- peerWireCapsBinary = false
1276
- peerFilePayloadV2 = false
1277
- BleJsonCompact.resetSession()
1299
+ resetWireNegotiationState()
1300
+ resetAncsRelayState()
1301
+ // Queued writes are session-bound: transmitting them into the NEXT session
1302
+ // (e.g. a stale handshake whose reply activates v2 before the new session
1303
+ // negotiated) is the bug class this clear removes.
1304
+ Task { await commandQueue.removeAll() }
1278
1305
  stopSignalStrengthPolling()
1279
1306
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
1280
1307
  DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
@@ -1298,6 +1325,7 @@ class MentraLive: NSObject, SGCManager {
1298
1325
  func sendText(_ text: String) async {
1299
1326
  await sendTextWall(text)
1300
1327
  }
1328
+
1301
1329
  func sendTextWall(_: String) async {}
1302
1330
  func ping() {
1303
1331
  Bridge.log("LIVE: ping()")
@@ -1410,6 +1438,10 @@ class MentraLive: NSObject, SGCManager {
1410
1438
  // So we temporarily suspend the LC3 mic during phone audio playback
1411
1439
  private var micIntentEnabled = false // User/system WANTS mic enabled
1412
1440
  private var micSuspendedForAudio = false // Mic temporarily suspended due to phone audio
1441
+ var isMicSuspendedForAudio: Bool {
1442
+ micSuspendedForAudio
1443
+ }
1444
+
1413
1445
  private var phoneAudioMonitor: PhoneAudioMonitor?
1414
1446
 
1415
1447
  // Timing Constants
@@ -1453,12 +1485,36 @@ class MentraLive: NSObject, SGCManager {
1453
1485
  private var peerWireProtocolVersion = 0
1454
1486
  private var useBinaryWireProtocol = false
1455
1487
  private var wireHandshakeQueued = false
1488
+ private var wireHandshakeAttempts = 0
1489
+ /// Session generation in which the LAST outgoing v2 handshake was sent; a reply only
1490
+ /// activates v2 when it answers a handshake from the CURRENT epoch (see PendingMessage
1491
+ /// .generation for the queue-side guard).
1492
+ private var wireHandshakeSentGeneration = -1
1493
+ /// Bumped on every BLE session reset; scheduled handshake-retry callbacks capture it
1494
+ /// and no-op if the session changed, so a timer from a dead session can't poke a new one.
1495
+ private var wireSessionGeneration = 0
1496
+ // Grace period before an unanswered BLE wire v2 handshake is re-armed for retry,
1497
+ // and the per-session cap on automatic retries (see sendWireHandshake).
1498
+ private static let wireHandshakeRetryGraceSeconds: TimeInterval = 5
1499
+ private static let wireHandshakeMaxAttempts = 3
1456
1500
  // Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to legacy
1457
1501
  // big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le (or a
1458
1502
  // v2 binary handshake succeeds, which implies wire-v2 LE).
1459
1503
  private var peerK900Le = false
1504
+ private var ancsAppIdentifiers: [UInt32: String] = [:]
1505
+ private var ancsRelayEnableRequested = false
1460
1506
  private var peerWireCapsBinary = false
1461
1507
  private var peerFilePayloadV2 = false
1508
+ /// Last observed glasses process session id (`sid` in glasses_ready / version_info_1).
1509
+ /// The BES keeps the BLE link alive across asg_client restarts, so transport state
1510
+ /// cannot signal a restart - a CHANGED (or newly appearing) sid is the restart signal.
1511
+ /// Nil = no sid observed this BLE session (legacy glasses, or none seen yet).
1512
+ private var glassesSessionId: String?
1513
+ // True once a glasses_ready completed on THIS physical BLE session. Unlike
1514
+ // fullyBooted, this never flaps on sr_hrt ready=0 heartbeats — it only resets with
1515
+ // the physical connection — so a first-seen sid after an upgrade OTA cannot be
1516
+ // mistaken for a fresh pairing just because a not-ready heartbeat slipped in first.
1517
+ private var readinessCompletedThisBleSession = false
1462
1518
  private var globalMessageId = 0
1463
1519
  private var lastReceivedMessageId = 0
1464
1520
  private var bleWriteTraceSequence = 0
@@ -1705,7 +1761,7 @@ class MentraLive: NSObject, SGCManager {
1705
1761
  let bleImgId =
1706
1762
  "I" + String(format: "%09d", Int(Date().timeIntervalSince1970 * 1000) % 100_000_000)
1707
1763
  json["bleImgId"] = bleImgId
1708
- json["transferMethod"] = "auto"
1764
+ json["transferMethod"] = request.transferMethod
1709
1765
 
1710
1766
  if let webhookUrl = request.webhookUrl, !webhookUrl.isEmpty {
1711
1767
  json["webhookUrl"] = webhookUrl
@@ -1746,7 +1802,9 @@ class MentraLive: NSObject, SGCManager {
1746
1802
  }
1747
1803
  request.appendScanFields(to: &json)
1748
1804
 
1749
- Bridge.log("LIVE: PHOTO PIPELINE [5b/6] take_photo JSON ready bleImgId=\(bleImgId) transferMethod=auto")
1805
+ Bridge.log(
1806
+ "LIVE: PHOTO PIPELINE [5b/6] take_photo JSON ready bleImgId=\(bleImgId) transferMethod=\(request.transferMethod)"
1807
+ )
1750
1808
  Bridge.log("LIVE: PHOTO PIPELINE [6/6] Dispatching take_photo to sendJson()")
1751
1809
 
1752
1810
  sendJson(json, wakeUp: true)
@@ -1755,11 +1813,14 @@ class MentraLive: NSObject, SGCManager {
1755
1813
  func warmUpCamera(
1756
1814
  requestId: String,
1757
1815
  size: PhotoSize,
1816
+ mode: PhotoMode = .photo,
1758
1817
  exposureTimeNs: Double?,
1759
- durationMs: Int
1818
+ durationMs: Int,
1819
+ zsl: Bool? = nil,
1820
+ mfnr: Bool? = nil
1760
1821
  ) {
1761
1822
  Bridge.log(
1762
- "LIVE: warmUpCamera() entry requestId=\(requestId) size=\(size.rawValue) durationMs=\(durationMs)"
1823
+ "LIVE: warmUpCamera() entry requestId=\(requestId) size=\(size.rawValue) mode=\(mode.rawValue) durationMs=\(durationMs)"
1763
1824
  )
1764
1825
 
1765
1826
  let allowedSizes = ["low", "medium", "high", "max"]
@@ -1768,16 +1829,30 @@ class MentraLive: NSObject, SGCManager {
1768
1829
  "type": "camera_warm_up",
1769
1830
  "requestId": requestId,
1770
1831
  "size": allowedSizes.contains(sizeRaw) ? sizeRaw : "medium",
1832
+ "mode": mode.rawValue,
1771
1833
  "durationMs": durationMs > 0 ? durationMs : 15000,
1772
1834
  ]
1773
1835
 
1774
1836
  if let e = exposureTimeNs, e.isFinite, e > 0, e <= Double(Int64.max) {
1775
1837
  json["exposureTimeNs"] = Int64(e)
1776
1838
  }
1839
+ if let mfnr {
1840
+ json["mfnr"] = mfnr
1841
+ }
1842
+ if let zsl {
1843
+ json["zsl"] = zsl
1844
+ }
1777
1845
 
1778
1846
  sendJson(json, wakeUp: true)
1779
1847
  }
1780
1848
 
1849
+ func stopCameraWarmUp(requestId: String) {
1850
+ sendJson(
1851
+ ["type": "camera_warm_up_stop", "requestId": requestId],
1852
+ wakeUp: true
1853
+ )
1854
+ }
1855
+
1781
1856
  func startStream(_ message: [String: Any]) {
1782
1857
  Bridge.log("Starting stream")
1783
1858
  var json = message
@@ -1819,17 +1894,24 @@ class MentraLive: NSObject, SGCManager {
1819
1894
  // MARK: - Command Queue
1820
1895
 
1821
1896
  class PendingMessage {
1822
- init(data: Data, id: String, retries: Int, trace: BleWriteTrace? = nil) {
1897
+ init(data: Data, id: String, retries: Int, trace: BleWriteTrace? = nil, generation: Int = 0) {
1823
1898
  self.data = data
1824
1899
  self.id = id
1825
1900
  self.retries = retries
1826
1901
  self.trace = trace
1902
+ self.generation = generation
1827
1903
  }
1828
1904
 
1829
1905
  let data: Data
1830
1906
  let retries: Int
1831
1907
  let id: String
1832
1908
  let trace: BleWriteTrace?
1909
+ /// Wire-session generation at enqueue time; the drain loop drops entries from a
1910
+ /// previous session so a stale write (worst case: an old handshake whose reply
1911
+ /// would activate v2 pre-negotiation) can never cross a session boundary. This is
1912
+ /// the deterministic guard - the async removeAll() on disconnect is best-effort
1913
+ /// cleanup that may land after a reconnect has already resumed the drain loop.
1914
+ let generation: Int
1833
1915
  }
1834
1916
 
1835
1917
  struct OutgoingBleCommandTraceInfo {
@@ -1875,6 +1957,10 @@ class MentraLive: NSObject, SGCManager {
1875
1957
  guard !commands.isEmpty else { return nil }
1876
1958
  return commands.removeFirst()
1877
1959
  }
1960
+
1961
+ func removeAll() {
1962
+ commands.removeAll()
1963
+ }
1878
1964
  }
1879
1965
 
1880
1966
  private func setupCommandQueue() {
@@ -1884,7 +1970,12 @@ class MentraLive: NSObject, SGCManager {
1884
1970
  let pendingIsNil = await MainActor.run { self.pending == nil }
1885
1971
  if pendingIsNil {
1886
1972
  if let command = await self.commandQueue.dequeue() {
1887
- await self.processSendQueue(command)
1973
+ let currentGeneration = await MainActor.run { self.wireSessionGeneration }
1974
+ if command.generation != currentGeneration {
1975
+ Bridge.log("LIVE: Dropping stale queued write from a previous session epoch (id: \(command.id))")
1976
+ } else {
1977
+ await self.processSendQueue(command)
1978
+ }
1888
1979
  }
1889
1980
  }
1890
1981
  try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
@@ -1960,7 +2051,8 @@ class MentraLive: NSObject, SGCManager {
1960
2051
  data: pendingMessage.data,
1961
2052
  id: pendingMessage.id,
1962
2053
  retries: pendingMessage.retries + 1,
1963
- trace: pendingMessage.trace
2054
+ trace: pendingMessage.trace,
2055
+ generation: pendingMessage.generation
1964
2056
  )
1965
2057
 
1966
2058
  // Push to front of queue for immediate retry
@@ -2054,7 +2146,36 @@ class MentraLive: NSObject, SGCManager {
2054
2146
  // Set connection timeout
2055
2147
  startConnectionTimeout()
2056
2148
 
2057
- centralManager?.connect(peripheral, options: nil)
2149
+ // ANCS is hosted by iOS and is only exposed to authorized accessories.
2150
+ // Requiring it at connect time lets the system complete that authorization
2151
+ // flow before the glasses subscribe to the ANCS characteristics.
2152
+ centralManager?.connect(
2153
+ peripheral,
2154
+ options: [CBConnectPeripheralOptionRequiresANCS: true]
2155
+ )
2156
+ }
2157
+
2158
+ /// Opt the firmware into ANCS only after iOS has authorized this accessory.
2159
+ /// Old firmware safely ignores the command, while new firmware stays inert
2160
+ /// for old mobile clients that never send it.
2161
+ private func enableAncsRelayIfAuthorized() {
2162
+ guard !ancsRelayEnableRequested,
2163
+ let peripheral = connectedPeripheral,
2164
+ txCharacteristic != nil,
2165
+ rxCharacteristic?.isNotifying == true,
2166
+ peripheral.ancsAuthorized
2167
+ else {
2168
+ return
2169
+ }
2170
+
2171
+ let command: [String: Any] = [
2172
+ "C": "cs_ancs",
2173
+ "B": ["enabled": 1],
2174
+ ]
2175
+ if sendRawK900Command(command) {
2176
+ ancsRelayEnableRequested = true
2177
+ Bridge.log("LIVE: Requested ANCS relay from compatible firmware")
2178
+ }
2058
2179
  }
2059
2180
 
2060
2181
  private func handleReconnection() {
@@ -2070,6 +2191,10 @@ class MentraLive: NSObject, SGCManager {
2070
2191
  updateConnectionState(ConnTypes.DISCONNECTED)
2071
2192
  connected = false
2072
2193
  fullyBooted = false
2194
+ glassesSessionId = nil
2195
+ readinessCompletedThisBleSession = false
2196
+ readinessCompletedThisBleSession = false // Fresh BLE session starts with no sid known
2197
+ readinessCompletedThisBleSession = false
2073
2198
  return
2074
2199
  }
2075
2200
 
@@ -2320,7 +2445,19 @@ class MentraLive: NSObject, SGCManager {
2320
2445
 
2321
2446
  switch type {
2322
2447
  case "glasses_ready":
2448
+ // glasses_ready is a REMOTE wire-session reset: the glasses ran
2449
+ // onTransportReset() before sending it, so their side is back on legacy
2450
+ // framing regardless of what this side negotiated earlier. Start a fresh
2451
+ // wire epoch (clears v2-active state that would otherwise gate the
2452
+ // handshake off), then negotiate from the caps this message advertises.
2453
+ resetWireNegotiationState()
2323
2454
  parsePeerWireCaps(json)
2455
+ maybeSendWireHandshake()
2456
+ // Record the session id BEFORE marking ready: an unsolicited glasses_ready
2457
+ // already runs this full remote-reset flow, so recording (not re-triggering)
2458
+ // is correct here; version_info detection covers the restart case.
2459
+ if let sid = json["sid"] as? String, !sid.isEmpty { glassesSessionId = sid }
2460
+ readinessCompletedThisBleSession = true
2324
2461
  handleGlassesReady()
2325
2462
 
2326
2463
  case "battery_status":
@@ -2355,7 +2492,7 @@ class MentraLive: NSObject, SGCManager {
2355
2492
  } else if connected {
2356
2493
  DeviceStore.shared.apply("glasses", "wifiError", "")
2357
2494
  }
2358
- updateWifiStatus(connected: connected, ssid: ssid, ip: ip)
2495
+ updateWifiStatus(connected: connected, ssid: ssid, ip: ip, error: wifiError.isEmpty ? nil : wifiError)
2359
2496
 
2360
2497
  case "hotspot_status_update":
2361
2498
  let enabled = json["hotspot_enabled"] as? Bool ?? false
@@ -2582,7 +2719,7 @@ class MentraLive: NSObject, SGCManager {
2582
2719
  default:
2583
2720
  // Flexible version_info parsing - handle any version_info* message
2584
2721
  if type.hasPrefix("version_info") {
2585
- Bridge.log("LIVE: Received \(type): \(json)")
2722
+ Bridge.log("LIVE: Received \(type)")
2586
2723
 
2587
2724
  // Extract all fields from JSON (except "type")
2588
2725
  var fields: [String: Any] = [:]
@@ -2599,8 +2736,6 @@ class MentraLive: NSObject, SGCManager {
2599
2736
  if let buildNumber = fields["build_number"] as? String {
2600
2737
  isNewVersion = (Int(buildNumber) ?? 0) >= 5
2601
2738
  DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
2602
- parsePeerWireCaps(json)
2603
- maybeSendWireHandshake()
2604
2739
  }
2605
2740
  if let deviceModel = fields["device_model"] as? String {
2606
2741
  DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
@@ -2628,10 +2763,24 @@ class MentraLive: NSObject, SGCManager {
2628
2763
  if let bluetoothMacAddress = fields["bt_mac_address"] as? String {
2629
2764
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
2630
2765
  }
2766
+ if let serialNumber = fields["serial_number"] as? String, !serialNumber.isEmpty {
2767
+ DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
2768
+ }
2631
2769
  if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
2632
2770
  DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2633
2771
  }
2634
2772
 
2773
+ // Negotiate wire caps from ANY version_info chunk, not only the one
2774
+ // carrying build_number: the glasses put wire_caps in version_info_3
2775
+ // (firmware chunk) while build_number travels in version_info_1, so
2776
+ // gating negotiation on build_number silently discards the caps.
2777
+ // parsePeerWireCaps no-ops without a wire_caps key and
2778
+ // maybeSendWireHandshake gates on caps + build + not-yet-queued, so
2779
+ // running them per chunk is safe.
2780
+ parsePeerWireCaps(json)
2781
+ maybeSendWireHandshake()
2782
+ handleGlassesSessionId(json)
2783
+
2635
2784
  Bridge.sendVersionInfo(fields)
2636
2785
  } else {
2637
2786
  Bridge.log("Unhandled message type: \(type)")
@@ -2729,6 +2878,11 @@ class MentraLive: NSObject, SGCManager {
2729
2878
  }
2730
2879
 
2731
2880
  switch command {
2881
+ case "sr_ancs":
2882
+ if let body = k900ParseBody(json["B"]) {
2883
+ processAncsRelay(body)
2884
+ }
2885
+
2732
2886
  case "sr_hrt":
2733
2887
  if let bodyObj = json["B"] as? [String: Any] {
2734
2888
  let readyResponse = bodyObj["ready"] as? Int ?? 0
@@ -2885,7 +3039,17 @@ class MentraLive: NSObject, SGCManager {
2885
3039
 
2886
3040
  let syntheticStatus: String
2887
3041
  if besOtaStatus == "FINISHED" {
2888
- syntheticStatus = "step_complete"
3042
+ // The glasses power-cycle right after the final BES tick, so a session
3043
+ // whose BES step is the LAST step never gets a follow-up ota_status from
3044
+ // the glasses — consumers mapping on this synthetic status would otherwise
3045
+ // never see a terminal state. Emit "complete" for the final step;
3046
+ // mid-session BES steps keep "step_complete" so session-level trackers
3047
+ // advance normally. Unknown sessions (cachedOtaTotalSteps == 0, e.g.
3048
+ // legacy glasses that never sent an ota_status) conservatively keep
3049
+ // "step_complete".
3050
+ syntheticStatus = (cachedOtaTotalSteps > 0 && cachedOtaCurrentStep >= cachedOtaTotalSteps)
3051
+ ? "complete"
3052
+ : "step_complete"
2889
3053
  } else if besOtaStatus == "FAILED" {
2890
3054
  syntheticStatus = "failed"
2891
3055
  } else {
@@ -2933,11 +3097,87 @@ class MentraLive: NSObject, SGCManager {
2933
3097
  }
2934
3098
  }
2935
3099
 
3100
+ /// Convert a Mentra Live firmware ANCS relay packet into the same native
3101
+ /// events used by Android's NotificationListenerService.
3102
+ private func processAncsRelay(_ body: [String: Any]) {
3103
+ guard let event = body["event"] as? String,
3104
+ let uid = (body["uid"] as? NSNumber)?.uint32Value
3105
+ else {
3106
+ Bridge.log("LIVE: Ignoring malformed ANCS relay packet")
3107
+ return
3108
+ }
3109
+
3110
+ let notificationId = "ancs-\(uid)"
3111
+ let appIdentifier = body["appIdentifier"] as? String ?? ""
3112
+
3113
+ if event == "removed" {
3114
+ let removedAppIdentifier = ancsAppIdentifiers.removeValue(forKey: uid) ?? appIdentifier
3115
+ Bridge.sendTypedMessage(
3116
+ "phone_notification_dismissed",
3117
+ body: [
3118
+ "notificationId": notificationId,
3119
+ "notificationKey": notificationId,
3120
+ "packageName": removedAppIdentifier,
3121
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3122
+ ]
3123
+ )
3124
+ return
3125
+ }
3126
+
3127
+ guard event == "added" || event == "modified" else {
3128
+ Bridge.log("LIVE: Ignoring unknown ANCS event: \(event)")
3129
+ return
3130
+ }
3131
+
3132
+ let title = body["title"] as? String ?? ""
3133
+ let subtitle = body["subtitle"] as? String ?? ""
3134
+ let message = body["message"] as? String ?? ""
3135
+ let content = [subtitle, message]
3136
+ .filter { !$0.isEmpty }
3137
+ .joined(separator: "\n")
3138
+ let flags = (body["flags"] as? NSNumber)?.uint8Value ?? 0
3139
+ if !appIdentifier.isEmpty {
3140
+ ancsAppIdentifiers[uid] = appIdentifier
3141
+ }
3142
+
3143
+ Bridge.sendTypedMessage(
3144
+ "phone_notification",
3145
+ body: [
3146
+ "notificationId": notificationId,
3147
+ "app": appIdentifier,
3148
+ "title": title,
3149
+ "content": content,
3150
+ "priority": (flags & 0x02) != 0 ? "1" : "0",
3151
+ "timestamp": ancsTimestamp(body["date"] as? String),
3152
+ "packageName": appIdentifier,
3153
+ ]
3154
+ )
3155
+ }
3156
+
3157
+ private func ancsTimestamp(_ value: String?) -> Int64 {
3158
+ guard let value, !value.isEmpty else {
3159
+ return Int64(Date().timeIntervalSince1970 * 1000)
3160
+ }
3161
+
3162
+ let formatter = DateFormatter()
3163
+ formatter.locale = Locale(identifier: "en_US_POSIX")
3164
+ formatter.calendar = Calendar(identifier: .gregorian)
3165
+ formatter.timeZone = .current
3166
+ formatter.dateFormat = "yyyyMMdd'T'HHmmss"
3167
+ guard let date = formatter.date(from: value) else {
3168
+ return Int64(Date().timeIntervalSince1970 * 1000)
3169
+ }
3170
+ return Int64(date.timeIntervalSince1970 * 1000)
3171
+ }
3172
+
2936
3173
  // commands to send to the glasses:
2937
3174
 
2938
- func requestWifiScan() {
3175
+ func requestWifiScan(scanId: String?) {
2939
3176
  Bridge.log("LIVE: Requesting WiFi scan from glasses")
2940
- let json: [String: Any] = ["type": "request_wifi_scan"]
3177
+ var json: [String: Any] = ["type": "request_wifi_scan"]
3178
+ if let scanId, !scanId.isEmpty {
3179
+ json["scanId"] = scanId
3180
+ }
2941
3181
  sendJson(json, wakeUp: true)
2942
3182
  }
2943
3183
 
@@ -3084,7 +3324,8 @@ class MentraLive: NSObject, SGCManager {
3084
3324
  "timestamp": Int(Date().timeIntervalSince1970 * 1000),
3085
3325
  ]
3086
3326
  if let otaVersionUrl = otaVersionUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
3087
- !otaVersionUrl.isEmpty {
3327
+ !otaVersionUrl.isEmpty
3328
+ {
3088
3329
  json["ota_version_url"] = otaVersionUrl
3089
3330
  }
3090
3331
 
@@ -3129,6 +3370,10 @@ class MentraLive: NSObject, SGCManager {
3129
3370
  DeviceStore.shared.apply("glasses", "appVersion", "")
3130
3371
  DeviceStore.shared.apply("glasses", "besFirmwareVersion", "")
3131
3372
  DeviceStore.shared.apply("glasses", "mtkFirmwareVersion", "")
3373
+ // Modern ASG builds omit ota_version_url entirely (the phone owns manifest selection),
3374
+ // and the chunked parser only writes present fields — clear it here so a URL reported
3375
+ // by a previous build cannot leak into this session.
3376
+ DeviceStore.shared.apply("glasses", "otaVersionUrl", "")
3132
3377
  Bridge.log("LIVE: Cleared cached version_info fields before refresh")
3133
3378
 
3134
3379
  // Perform SOC-dependent initialization
@@ -3177,7 +3422,8 @@ class MentraLive: NSObject, SGCManager {
3177
3422
  }
3178
3423
 
3179
3424
  let scanComplete = json["scan_complete"] as? Bool ?? json["scanComplete"] as? Bool ?? false
3180
- Bridge.updateWifiScanResults(networks, scanComplete: scanComplete)
3425
+ let scanId = (json["scanId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
3426
+ Bridge.updateWifiScanResults(networks, scanComplete: scanComplete, scanId: scanId)
3181
3427
  }
3182
3428
 
3183
3429
  private func handleButtonPress(_ json: [String: Any]) {
@@ -3196,12 +3442,16 @@ class MentraLive: NSObject, SGCManager {
3196
3442
  let otaVersionUrl = json["ota_version_url"] as? String ?? ""
3197
3443
  let firmwareVersion = json["firmware_version"] as? String ?? ""
3198
3444
  let bluetoothMacAddress = json["bt_mac_address"] as? String ?? ""
3445
+ let serialNumber = json["serial_number"] as? String ?? ""
3199
3446
 
3200
3447
  DeviceStore.shared.apply("glasses", "appVersion", appVersion)
3201
3448
  DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
3202
3449
  DeviceStore.shared.apply("glasses", "otaVersionUrl", otaVersionUrl)
3203
3450
  DeviceStore.shared.apply("glasses", "firmwareVersion", firmwareVersion)
3204
3451
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
3452
+ if !serialNumber.isEmpty {
3453
+ DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
3454
+ }
3205
3455
  isNewVersion = (Int(buildNumber) ?? 0) >= 5
3206
3456
  maybeSendWireHandshake()
3207
3457
  DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
@@ -3803,8 +4053,8 @@ class MentraLive: NSObject, SGCManager {
3803
4053
  responseBody: String
3804
4054
  ) {
3805
4055
  guard !responseBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
3806
- let data = responseBody.data(using: .utf8),
3807
- let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
4056
+ let data = responseBody.data(using: .utf8),
4057
+ let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
3808
4058
  else {
3809
4059
  return
3810
4060
  }
@@ -3861,8 +4111,12 @@ class MentraLive: NSObject, SGCManager {
3861
4111
 
3862
4112
  func queueSend(_ data: Data, id: String, trace: BleWriteTrace?) {
3863
4113
  let significantQueueSize = SIGNIFICANT_BLE_TRACE_QUEUE_SIZE
4114
+ // Capture the epoch at enqueue-request time: the Task body may run after a
4115
+ // session reset, and a pre-reset payload stamped with the new generation would
4116
+ // defeat the stale-write guard in the drain loop.
4117
+ let generation = wireSessionGeneration
3864
4118
  Task {
3865
- let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace))
4119
+ let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
3866
4120
  guard trace != nil, queueSize >= significantQueueSize else {
3867
4121
  return
3868
4122
  }
@@ -4092,19 +4346,47 @@ class MentraLive: NSObject, SGCManager {
4092
4346
  }
4093
4347
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
4094
4348
  wireHandshakeQueued = true
4349
+ wireHandshakeSentGeneration = wireSessionGeneration
4095
4350
  queueSend(packed, id: "-1")
4351
+ // The handshake and its reply are fire-and-forget binary frames with no ACK
4352
+ // tracking; if either is lost, wireHandshakeQueued would block every future
4353
+ // attempt and the session would silently stay on the legacy string wire.
4354
+ // Re-arm after a grace period so negotiation can retry, bounded per session
4355
+ // so a peer that advertises binary but never answers doesn't get pinged
4356
+ // forever (later caps/version triggers may still retry explicitly).
4357
+ wireHandshakeAttempts += 1
4358
+ let scheduledGeneration = wireSessionGeneration
4359
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.wireHandshakeRetryGraceSeconds) { [weak self] in
4360
+ guard let self else { return }
4361
+ guard scheduledGeneration == self.wireSessionGeneration else { return }
4362
+ if self.wireHandshakeQueued, self.peerWireProtocolVersion < BleWireProtocol.protocolV2 {
4363
+ Bridge.log(
4364
+ "LIVE: BLE wire v2 handshake unanswered after \(Self.wireHandshakeRetryGraceSeconds)s " +
4365
+ "(attempt \(self.wireHandshakeAttempts)/\(Self.wireHandshakeMaxAttempts)) - re-arming"
4366
+ )
4367
+ self.wireHandshakeQueued = false
4368
+ if self.wireHandshakeAttempts < Self.wireHandshakeMaxAttempts {
4369
+ self.maybeSendWireHandshake()
4370
+ }
4371
+ }
4372
+ }
4096
4373
  }
4097
4374
 
4098
4375
  private func activateBinaryWireV2Session(logMessage: String) {
4099
4376
  peerWireProtocolVersion = BleWireProtocol.protocolV2
4100
4377
  useBinaryWireProtocol = true
4101
4378
  wireHandshakeQueued = false
4379
+ wireHandshakeAttempts = 0
4102
4380
  peerK900Le = true
4103
4381
  BleJsonCompact.markSessionConnected(epochMs: Int64(Date().timeIntervalSince1970 * 1000))
4104
4382
  Bridge.log(logMessage)
4105
4383
  }
4106
4384
 
4107
4385
  private func handlePeerWireHandshake() {
4386
+ if wireHandshakeSentGeneration != wireSessionGeneration {
4387
+ Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
4388
+ return
4389
+ }
4108
4390
  activateBinaryWireV2Session(logMessage: "LIVE: Peer confirmed BLE wire protocol v2")
4109
4391
  }
4110
4392
 
@@ -4624,12 +4906,12 @@ class MentraLive: NSObject, SGCManager {
4624
4906
  }
4625
4907
  }
4626
4908
 
4627
- private func updateWifiStatus(connected: Bool, ssid: String, ip: String) {
4909
+ private func updateWifiStatus(connected: Bool, ssid: String, ip: String, error: String? = nil) {
4628
4910
  Bridge.log("LIVE: 🌐 Updating WiFi status - connected: \(connected), ssid: \(ssid)")
4629
4911
  DeviceStore.shared.apply("glasses", "wifiConnected", connected)
4630
4912
  DeviceStore.shared.apply("glasses", "wifiSsid", ssid)
4631
4913
  DeviceStore.shared.apply("glasses", "wifiLocalIp", ip)
4632
- emitWifiStatusChange()
4914
+ emitWifiStatusChange(error: error)
4633
4915
  }
4634
4916
 
4635
4917
  private func updateHotspotStatus(enabled: Bool, ssid: String, password: String, ip: String) {
@@ -4815,6 +5097,7 @@ class MentraLive: NSObject, SGCManager {
4815
5097
  readinessCheckCounter = 0
4816
5098
  fullyBooted = false
4817
5099
  connected = false
5100
+ glassesSessionId = nil
4818
5101
 
4819
5102
  Bridge.log("LIVE: 🔄 Starting glasses SOC readiness check loop")
4820
5103
 
@@ -4923,8 +5206,8 @@ class MentraLive: NSObject, SGCManager {
4923
5206
  // emitEvent("BatteryLevelEvent", body: eventBody)
4924
5207
  // }
4925
5208
 
4926
- private func emitWifiStatusChange() {
4927
- Bridge.sendWifiStatusChange(connected: wifiConnected, ssid: wifiSsid, localIp: wifiLocalIp)
5209
+ private func emitWifiStatusChange(error: String? = nil) {
5210
+ Bridge.sendWifiStatusChange(connected: wifiConnected, ssid: wifiSsid, localIp: wifiLocalIp, error: error)
4928
5211
  }
4929
5212
 
4930
5213
  private func emitHotspotStatusChange() {
@@ -5006,14 +5289,8 @@ class MentraLive: NSObject, SGCManager {
5006
5289
 
5007
5290
  // Stop all timers
5008
5291
  stopAllTimers()
5009
- incomingChunkReassembler.clear()
5010
- peerWireProtocolVersion = 0
5011
- useBinaryWireProtocol = false
5012
- wireHandshakeQueued = false
5013
- peerK900Le = false
5014
- peerWireCapsBinary = false
5015
- peerFilePayloadV2 = false
5016
- BleJsonCompact.resetSession()
5292
+ resetWireNegotiationState()
5293
+ Task { await commandQueue.removeAll() } // stale writes die with the session
5017
5294
  closeL2capFileChannel()
5018
5295
 
5019
5296
  // Disconnect BLE
@@ -5106,6 +5383,77 @@ extension MentraLive {
5106
5383
  * per-link endianness and binary support. Missing wire_caps leaves the legacy defaults (BE, no
5107
5384
  * binary) untouched so older glasses keep working.
5108
5385
  */
5386
+ /// Drop every negotiated wire-session artifact and bump the session generation so
5387
+ /// scheduled callbacks from the old session stand down. Called on BLE disconnect and on
5388
+ /// glasses_ready: the glasses run onTransportReset() before sending glasses_ready, so a
5389
+ /// mid-link ASG restart (no BLE disconnect) resets THEIR side to legacy framing - the
5390
+ /// phone must treat every glasses_ready as a new wire epoch and renegotiate (bounded by
5391
+ /// the per-session handshake attempt cap) instead of trusting stale v2-active state.
5392
+ private func resetWireNegotiationState() {
5393
+ incomingChunkReassembler.clear()
5394
+ peerWireProtocolVersion = 0
5395
+ useBinaryWireProtocol = false
5396
+ wireHandshakeQueued = false
5397
+ wireHandshakeAttempts = 0
5398
+ wireSessionGeneration += 1
5399
+ peerK900Le = false
5400
+ peerWireCapsBinary = false
5401
+ peerFilePayloadV2 = false
5402
+ BleJsonCompact.resetSession()
5403
+ wireHandshakeSentGeneration = -1
5404
+ }
5405
+
5406
+ /// ANCS notification IDs live for the BLE link, not the wire epoch. A
5407
+ /// glasses_ready message may reset wire negotiation without disconnecting,
5408
+ /// so clear this state only when the BLE session itself ends.
5409
+ private func resetAncsRelayState() {
5410
+ ancsAppIdentifiers.removeAll()
5411
+ ancsRelayEnableRequested = false
5412
+ }
5413
+
5414
+ /// Sends phone_ready to (re)start the glasses-driven readiness flow.
5415
+ private func sendPhoneReady(reason: String) {
5416
+ Bridge.log("LIVE: 📱 Sending phone_ready to glasses (\(reason)) - waiting for glasses_ready response")
5417
+ let readyMsg: [String: Any] = [
5418
+ "type": "phone_ready",
5419
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
5420
+ ]
5421
+ sendJson(readyMsg, wakeUp: true)
5422
+ }
5423
+
5424
+ /// Tracks the glasses process session id (`sid`) and re-runs the phone-driven readiness
5425
+ /// flow when it changes under a live BLE link. The BES holds the link across asg_client
5426
+ /// restarts (APK OTA, crash recovery), so a restarted glasses process is invisible to
5427
+ /// transport state; the sid is the explicit protocol signal.
5428
+ ///
5429
+ /// Tri-state by design (retro-compat with pre-sid glasses builds): no `sid` key = legacy
5430
+ /// glasses, do nothing; sid appearing on an ESTABLISHED session (upgrade OTA from a
5431
+ /// pre-sid build) or differing from the recorded one = restart; same sid = no action.
5432
+ /// A restart is a LOGICAL session reset: send phone_ready immediately (bypassing the
5433
+ /// sr_hrt heartbeat's stale-readiness suppression); the returning glasses_ready runs
5434
+ /// the full existing remote-wire-reset flow. fullyBooted is deliberately untouched -
5435
+ /// the physical link never dropped, so the connection UI must not flap.
5436
+ private func handleGlassesSessionId(_ json: [String: Any]) {
5437
+ guard let sid = json["sid"] as? String, !sid.isEmpty else { return }
5438
+ let previous = glassesSessionId
5439
+ if sid == previous { return }
5440
+ glassesSessionId = sid
5441
+ if previous == nil, !readinessCompletedThisBleSession {
5442
+ // First sid of a fresh BLE session before readiness completes: the normal
5443
+ // pairing/readiness flow is already running - just record it.
5444
+ return
5445
+ }
5446
+ Bridge.log(
5447
+ "LIVE: 🔁 Glasses session changed (\(previous ?? "<pre-sid build>") -> \(sid)) - " +
5448
+ "asg restarted under a live link, re-running readiness"
5449
+ )
5450
+ sendPhoneReady(reason: "glasses session changed")
5451
+ Bridge.sendTypedMessage(
5452
+ "glasses_session_changed",
5453
+ body: ["previous_sid": previous ?? "", "sid": sid]
5454
+ )
5455
+ }
5456
+
5109
5457
  private func parsePeerWireCaps(_ json: [String: Any]) {
5110
5458
  guard let caps = json["wire_caps"] as? [String: Any] else { return }
5111
5459
  if (caps["k900_le"] as? Bool) == true {
@@ -5778,6 +6126,9 @@ extension MentraLive {
5778
6126
 
5779
6127
  // Send glasses-side Voice Activity Detection setting.
5780
6128
  sendVoiceActivityDetectionSetting()
6129
+
6130
+ // Send glasses-side loudness / Barrier gate setting.
6131
+ sendLoudnessGateSetting()
5781
6132
  }
5782
6133
 
5783
6134
  func sendVoiceActivityDetectionSetting() {
@@ -5814,6 +6165,38 @@ extension MentraLive {
5814
6165
  }
5815
6166
  }
5816
6167
 
6168
+ func sendLoudnessGateSetting() {
6169
+ let enabled = DeviceStore.shared.get("bluetooth", "loudness_gate_enabled") as? Bool
6170
+ ?? BluetoothSdkDefaults.loudnessGateEnabled
6171
+ Bridge.log("LIVE: 🎚️ Sending loudness/Barrier gate setting to glasses: \(enabled)")
6172
+
6173
+ guard connectedPeripheral != nil, txCharacteristic != nil else {
6174
+ Bridge.log("Cannot send loudness gate setting - BLE write path not ready")
6175
+ return
6176
+ }
6177
+
6178
+ do {
6179
+ let bodyData = try JSONSerialization.data(withJSONObject: [
6180
+ "type": Self.loudnessGateSwitchType,
6181
+ "switch": enabled ? 1 : 0,
6182
+ ])
6183
+ guard let bodyString = String(data: bodyData, encoding: .utf8) else {
6184
+ Bridge.log("LIVE: Failed to encode loudness gate payload")
6185
+ return
6186
+ }
6187
+ let command: [String: Any] = [
6188
+ "C": "cs_swit",
6189
+ "V": 1,
6190
+ "B": bodyString,
6191
+ ]
6192
+ if !sendRawK900Command(command, wakeUp: true) {
6193
+ Bridge.log("LIVE: Failed to send loudness gate setting command")
6194
+ }
6195
+ } catch {
6196
+ Bridge.log("LIVE: Error encoding loudness gate payload: \(error)")
6197
+ }
6198
+ }
6199
+
5817
6200
  func sendButtonVideoRecordingSettings() {
5818
6201
  let settings =
5819
6202
  DeviceStore.shared.get("bluetooth", "button_video_settings") as? [String: Any] ?? [
@@ -5879,11 +6262,12 @@ extension MentraLive {
5879
6262
  }
5880
6263
 
5881
6264
  func sendButtonPhotoSettings() {
6265
+ let legacyZslMfnr = DeviceStore.shared.get("bluetooth", "button_photo_zsl_mfnr") as? Bool
5882
6266
  let size = (DeviceStore.shared.get("bluetooth", "button_photo_size") as? String).flatMap { rawSize in
5883
6267
  rawSize.isEmpty ? nil : PhotoSize(normalizedRawValue: rawSize)
5884
6268
  }
5885
- let mfnr = DeviceStore.shared.get("bluetooth", "button_photo_mfnr") as? Bool
5886
- let zsl = DeviceStore.shared.get("bluetooth", "button_photo_zsl") as? Bool
6269
+ let mfnr = DeviceStore.shared.get("bluetooth", "button_photo_mfnr") as? Bool ?? legacyZslMfnr
6270
+ let zsl = DeviceStore.shared.get("bluetooth", "button_photo_zsl") as? Bool ?? legacyZslMfnr
5887
6271
  let noiseReduction = DeviceStore.shared.get("bluetooth", "button_photo_noise_reduction") as? Bool
5888
6272
  let edgeEnhancement = DeviceStore.shared.get("bluetooth", "button_photo_edge_enhancement") as? Bool
5889
6273
  let ispDigitalGain = DeviceStore.shared.get("bluetooth", "button_photo_isp_digital_gain") as? Int
@@ -6001,7 +6385,7 @@ extension MentraLive {
6001
6385
 
6002
6386
  func sendCameraFovSetting() {
6003
6387
  let settings = DeviceStore.shared.get("bluetooth", "camera_fov") as? [String: Any] ?? ["fov": 118, "roi_position": 0]
6004
- let fov = settings["fov"] as? Int ?? 118
6388
+ let fov = settings["fov"] as? Int ?? CameraFov.defaultFov
6005
6389
  let roiPosition = settings["roi_position"] as? Int ?? 0
6006
6390
  sendCameraFovSetting(requestId: nil, fov: fov, roiPosition: roiPosition)
6007
6391
  }
@@ -6027,6 +6411,39 @@ extension MentraLive {
6027
6411
  sendJson(json, wakeUp: true)
6028
6412
  }
6029
6413
 
6414
+ func sendCameraFovOverride(
6415
+ requestId: String,
6416
+ leaseId: String,
6417
+ fov: Int,
6418
+ roiPosition: Int,
6419
+ ttlMs: Int
6420
+ ) {
6421
+ sendJson(
6422
+ [
6423
+ "type": "camera_fov_override",
6424
+ "request_id": requestId,
6425
+ "params": [
6426
+ "lease_id": leaseId,
6427
+ "fov": fov,
6428
+ "roi_position": roiPosition,
6429
+ "ttl_ms": ttlMs,
6430
+ ],
6431
+ ],
6432
+ wakeUp: true
6433
+ )
6434
+ }
6435
+
6436
+ func releaseCameraFovOverride(requestId: String, leaseId: String) {
6437
+ sendJson(
6438
+ [
6439
+ "type": "camera_fov_override_release",
6440
+ "request_id": requestId,
6441
+ "params": ["lease_id": leaseId],
6442
+ ],
6443
+ wakeUp: true
6444
+ )
6445
+ }
6446
+
6030
6447
  func sendCameraTuningConfig(requestId: String?, anrOn: Bool, gainOn: Bool) {
6031
6448
  Bridge.log("Sending camera tuning config: anr=\(anrOn), gain=\(gainOn)")
6032
6449