@mentra/bluetooth-sdk 0.1.21-beta.0 → 0.1.21-beta.2

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 (89) hide show
  1. package/README.md +22 -11
  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 +43 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +7 -2
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +82 -6
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +32 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  12. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -4
  13. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +161 -14
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +41 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +30 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  36. package/build/BluetoothSdk.types.d.ts +58 -25
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js +2 -1
  39. package/build/BluetoothSdk.types.js.map +1 -1
  40. package/build/_internal.d.ts +1 -0
  41. package/build/_internal.d.ts.map +1 -1
  42. package/build/_internal.js +1 -0
  43. package/build/_internal.js.map +1 -1
  44. package/build/_private/BluetoothSdkModule.d.ts +8 -2
  45. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  46. package/build/_private/BluetoothSdkModule.js +6 -3
  47. package/build/_private/BluetoothSdkModule.js.map +1 -1
  48. package/build/_private/cameraRequestPayload.d.ts +1 -1
  49. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  50. package/build/_private/cameraRequestPayload.js +6 -0
  51. package/build/_private/cameraRequestPayload.js.map +1 -1
  52. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  53. package/build/_private/photoRequestPayload.js +10 -1
  54. package/build/_private/photoRequestPayload.js.map +1 -1
  55. package/build/_private/sdkOtaManifest.d.ts +20 -0
  56. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  57. package/build/_private/sdkOtaManifest.js +28 -0
  58. package/build/_private/sdkOtaManifest.js.map +1 -0
  59. package/build/index.d.ts +1 -1
  60. package/build/index.d.ts.map +1 -1
  61. package/build/index.js +7 -1
  62. package/build/index.js.map +1 -1
  63. package/ios/BluetoothSdkModule.swift +41 -2
  64. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  65. package/ios/Source/Bridge.swift +15 -3
  66. package/ios/Source/DeviceManager.swift +67 -17
  67. package/ios/Source/DeviceStore.swift +5 -1
  68. package/ios/Source/MentraBluetoothSDK.swift +37 -8
  69. package/ios/Source/OtaManifest.swift +47 -4
  70. package/ios/Source/camera/CameraModels.swift +20 -5
  71. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  72. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  73. package/ios/Source/sgcs/Ar99.swift +2451 -0
  74. package/ios/Source/sgcs/MentraLive.swift +174 -38
  75. package/ios/Source/sgcs/SGCManager.swift +8 -0
  76. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  77. package/ios/Source/streaming/StreamModels.swift +71 -7
  78. package/ios/Source/types/DeviceModels.swift +13 -0
  79. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  80. package/ios/Source/utils/Constants.swift +2 -0
  81. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  82. package/package.json +1 -1
  83. package/src/BluetoothSdk.types.ts +61 -28
  84. package/src/_internal.ts +1 -0
  85. package/src/_private/BluetoothSdkModule.ts +20 -6
  86. package/src/_private/cameraRequestPayload.ts +8 -2
  87. package/src/_private/photoRequestPayload.ts +12 -2
  88. package/src/_private/sdkOtaManifest.ts +31 -0
  89. package/src/index.ts +9 -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
@@ -950,6 +950,8 @@ extension MentraLive: CBCentralManagerDelegate {
950
950
  self.connectedPeripheral = nil
951
951
  self.fullyBooted = false
952
952
  self.connected = false
953
+ self.glassesSessionId = nil // Fresh BLE session starts with no sid known
954
+ self.readinessCompletedThisBleSession = false
953
955
  self.updateConnectionState(ConnTypes.DISCONNECTED)
954
956
  self.rgbLedAuthorityClaimed = false
955
957
 
@@ -1273,11 +1275,21 @@ class MentraLive: NSObject, SGCManager {
1273
1275
  // to avoid overloading the MCU. Set to false to allow simultaneous A2DP + LC3 mic.
1274
1276
  private let BLOCK_AUDIO_DUPLEX = false
1275
1277
  private static let voiceActivityDetectionSwitchType = 8
1278
+ private static let loudnessGateSwitchType = 10
1276
1279
 
1277
1280
  var connectionState: String = ConnTypes.DISCONNECTED
1278
1281
 
1279
1282
  /// Mirrors Android `updateConnectionState` — RN home reads `glasses.connectionState` for reconnecting UI.
1280
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
+ }
1281
1293
  connectionState = state
1282
1294
  DeviceStore.shared.apply("glasses", "connectionState", state)
1283
1295
  // Drop OTA caches when fully disconnected — avoids leaking session/step state from
@@ -1313,6 +1325,7 @@ class MentraLive: NSObject, SGCManager {
1313
1325
  func sendText(_ text: String) async {
1314
1326
  await sendTextWall(text)
1315
1327
  }
1328
+
1316
1329
  func sendTextWall(_: String) async {}
1317
1330
  func ping() {
1318
1331
  Bridge.log("LIVE: ping()")
@@ -1425,7 +1438,10 @@ class MentraLive: NSObject, SGCManager {
1425
1438
  // So we temporarily suspend the LC3 mic during phone audio playback
1426
1439
  private var micIntentEnabled = false // User/system WANTS mic enabled
1427
1440
  private var micSuspendedForAudio = false // Mic temporarily suspended due to phone audio
1428
- var isMicSuspendedForAudio: Bool { micSuspendedForAudio }
1441
+ var isMicSuspendedForAudio: Bool {
1442
+ micSuspendedForAudio
1443
+ }
1444
+
1429
1445
  private var phoneAudioMonitor: PhoneAudioMonitor?
1430
1446
 
1431
1447
  // Timing Constants
@@ -1470,12 +1486,12 @@ class MentraLive: NSObject, SGCManager {
1470
1486
  private var useBinaryWireProtocol = false
1471
1487
  private var wireHandshakeQueued = false
1472
1488
  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).
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).
1476
1492
  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.
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.
1479
1495
  private var wireSessionGeneration = 0
1480
1496
  // Grace period before an unanswered BLE wire v2 handshake is re-armed for retry,
1481
1497
  // and the per-session cap on automatic retries (see sendWireHandshake).
@@ -1489,6 +1505,16 @@ class MentraLive: NSObject, SGCManager {
1489
1505
  private var ancsRelayEnableRequested = false
1490
1506
  private var peerWireCapsBinary = false
1491
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
1492
1518
  private var globalMessageId = 0
1493
1519
  private var lastReceivedMessageId = 0
1494
1520
  private var bleWriteTraceSequence = 0
@@ -1789,7 +1815,9 @@ class MentraLive: NSObject, SGCManager {
1789
1815
  size: PhotoSize,
1790
1816
  mode: PhotoMode = .photo,
1791
1817
  exposureTimeNs: Double?,
1792
- durationMs: Int
1818
+ durationMs: Int,
1819
+ zsl: Bool? = nil,
1820
+ mfnr: Bool? = nil
1793
1821
  ) {
1794
1822
  Bridge.log(
1795
1823
  "LIVE: warmUpCamera() entry requestId=\(requestId) size=\(size.rawValue) mode=\(mode.rawValue) durationMs=\(durationMs)"
@@ -1808,6 +1836,12 @@ class MentraLive: NSObject, SGCManager {
1808
1836
  if let e = exposureTimeNs, e.isFinite, e > 0, e <= Double(Int64.max) {
1809
1837
  json["exposureTimeNs"] = Int64(e)
1810
1838
  }
1839
+ if let mfnr {
1840
+ json["mfnr"] = mfnr
1841
+ }
1842
+ if let zsl {
1843
+ json["zsl"] = zsl
1844
+ }
1811
1845
 
1812
1846
  sendJson(json, wakeUp: true)
1813
1847
  }
@@ -1872,11 +1906,11 @@ class MentraLive: NSObject, SGCManager {
1872
1906
  let retries: Int
1873
1907
  let id: String
1874
1908
  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.
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.
1880
1914
  let generation: Int
1881
1915
  }
1882
1916
 
@@ -2157,6 +2191,10 @@ class MentraLive: NSObject, SGCManager {
2157
2191
  updateConnectionState(ConnTypes.DISCONNECTED)
2158
2192
  connected = false
2159
2193
  fullyBooted = false
2194
+ glassesSessionId = nil
2195
+ readinessCompletedThisBleSession = false
2196
+ readinessCompletedThisBleSession = false // Fresh BLE session starts with no sid known
2197
+ readinessCompletedThisBleSession = false
2160
2198
  return
2161
2199
  }
2162
2200
 
@@ -2415,6 +2453,11 @@ class MentraLive: NSObject, SGCManager {
2415
2453
  resetWireNegotiationState()
2416
2454
  parsePeerWireCaps(json)
2417
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
2418
2461
  handleGlassesReady()
2419
2462
 
2420
2463
  case "battery_status":
@@ -2676,7 +2719,7 @@ class MentraLive: NSObject, SGCManager {
2676
2719
  default:
2677
2720
  // Flexible version_info parsing - handle any version_info* message
2678
2721
  if type.hasPrefix("version_info") {
2679
- Bridge.log("LIVE: Received \(type): \(json)")
2722
+ Bridge.log("LIVE: Received \(type)")
2680
2723
 
2681
2724
  // Extract all fields from JSON (except "type")
2682
2725
  var fields: [String: Any] = [:]
@@ -2720,6 +2763,9 @@ class MentraLive: NSObject, SGCManager {
2720
2763
  if let bluetoothMacAddress = fields["bt_mac_address"] as? String {
2721
2764
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
2722
2765
  }
2766
+ if let serialNumber = fields["serial_number"] as? String, !serialNumber.isEmpty {
2767
+ DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
2768
+ }
2723
2769
  if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
2724
2770
  DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2725
2771
  }
@@ -2733,6 +2779,7 @@ class MentraLive: NSObject, SGCManager {
2733
2779
  // running them per chunk is safe.
2734
2780
  parsePeerWireCaps(json)
2735
2781
  maybeSendWireHandshake()
2782
+ handleGlassesSessionId(json)
2736
2783
 
2737
2784
  Bridge.sendVersionInfo(fields)
2738
2785
  } else {
@@ -3277,7 +3324,8 @@ class MentraLive: NSObject, SGCManager {
3277
3324
  "timestamp": Int(Date().timeIntervalSince1970 * 1000),
3278
3325
  ]
3279
3326
  if let otaVersionUrl = otaVersionUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
3280
- !otaVersionUrl.isEmpty {
3327
+ !otaVersionUrl.isEmpty
3328
+ {
3281
3329
  json["ota_version_url"] = otaVersionUrl
3282
3330
  }
3283
3331
 
@@ -3322,6 +3370,10 @@ class MentraLive: NSObject, SGCManager {
3322
3370
  DeviceStore.shared.apply("glasses", "appVersion", "")
3323
3371
  DeviceStore.shared.apply("glasses", "besFirmwareVersion", "")
3324
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", "")
3325
3377
  Bridge.log("LIVE: Cleared cached version_info fields before refresh")
3326
3378
 
3327
3379
  // Perform SOC-dependent initialization
@@ -3390,12 +3442,16 @@ class MentraLive: NSObject, SGCManager {
3390
3442
  let otaVersionUrl = json["ota_version_url"] as? String ?? ""
3391
3443
  let firmwareVersion = json["firmware_version"] as? String ?? ""
3392
3444
  let bluetoothMacAddress = json["bt_mac_address"] as? String ?? ""
3445
+ let serialNumber = json["serial_number"] as? String ?? ""
3393
3446
 
3394
3447
  DeviceStore.shared.apply("glasses", "appVersion", appVersion)
3395
3448
  DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
3396
3449
  DeviceStore.shared.apply("glasses", "otaVersionUrl", otaVersionUrl)
3397
3450
  DeviceStore.shared.apply("glasses", "firmwareVersion", firmwareVersion)
3398
3451
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
3452
+ if !serialNumber.isEmpty {
3453
+ DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
3454
+ }
3399
3455
  isNewVersion = (Int(buildNumber) ?? 0) >= 5
3400
3456
  maybeSendWireHandshake()
3401
3457
  DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
@@ -3997,8 +4053,8 @@ class MentraLive: NSObject, SGCManager {
3997
4053
  responseBody: String
3998
4054
  ) {
3999
4055
  guard !responseBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
4000
- let data = responseBody.data(using: .utf8),
4001
- 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]
4002
4058
  else {
4003
4059
  return
4004
4060
  }
@@ -5041,6 +5097,7 @@ class MentraLive: NSObject, SGCManager {
5041
5097
  readinessCheckCounter = 0
5042
5098
  fullyBooted = false
5043
5099
  connected = false
5100
+ glassesSessionId = nil
5044
5101
 
5045
5102
  Bridge.log("LIVE: 🔄 Starting glasses SOC readiness check loop")
5046
5103
 
@@ -5354,6 +5411,49 @@ extension MentraLive {
5354
5411
  ancsRelayEnableRequested = false
5355
5412
  }
5356
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
+
5357
5457
  private func parsePeerWireCaps(_ json: [String: Any]) {
5358
5458
  guard let caps = json["wire_caps"] as? [String: Any] else { return }
5359
5459
  if (caps["k900_le"] as? Bool) == true {
@@ -6026,6 +6126,9 @@ extension MentraLive {
6026
6126
 
6027
6127
  // Send glasses-side Voice Activity Detection setting.
6028
6128
  sendVoiceActivityDetectionSetting()
6129
+
6130
+ // Send glasses-side loudness / Barrier gate setting.
6131
+ sendLoudnessGateSetting()
6029
6132
  }
6030
6133
 
6031
6134
  func sendVoiceActivityDetectionSetting() {
@@ -6062,6 +6165,38 @@ extension MentraLive {
6062
6165
  }
6063
6166
  }
6064
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
+
6065
6200
  func sendButtonVideoRecordingSettings() {
6066
6201
  let settings =
6067
6202
  DeviceStore.shared.get("bluetooth", "button_video_settings") as? [String: Any] ?? [
@@ -6127,11 +6262,12 @@ extension MentraLive {
6127
6262
  }
6128
6263
 
6129
6264
  func sendButtonPhotoSettings() {
6265
+ let legacyZslMfnr = DeviceStore.shared.get("bluetooth", "button_photo_zsl_mfnr") as? Bool
6130
6266
  let size = (DeviceStore.shared.get("bluetooth", "button_photo_size") as? String).flatMap { rawSize in
6131
6267
  rawSize.isEmpty ? nil : PhotoSize(normalizedRawValue: rawSize)
6132
6268
  }
6133
- let mfnr = DeviceStore.shared.get("bluetooth", "button_photo_mfnr") as? Bool
6134
- 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
6135
6271
  let noiseReduction = DeviceStore.shared.get("bluetooth", "button_photo_noise_reduction") as? Bool
6136
6272
  let edgeEnhancement = DeviceStore.shared.get("bluetooth", "button_photo_edge_enhancement") as? Bool
6137
6273
  let ispDigitalGain = DeviceStore.shared.get("bluetooth", "button_photo_isp_digital_gain") as? Int
@@ -6248,8 +6384,8 @@ extension MentraLive {
6248
6384
  }
6249
6385
 
6250
6386
  func sendCameraFovSetting() {
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
6387
+ let settings = DeviceStore.shared.get("bluetooth", "camera_fov") as? [String: Any] ?? ["fov": 118, "roi_position": 0]
6388
+ let fov = settings["fov"] as? Int ?? CameraFov.defaultFov
6253
6389
  let roiPosition = settings["roi_position"] as? Int ?? 0
6254
6390
  sendCameraFovSetting(requestId: nil, fov: fov, roiPosition: roiPosition)
6255
6391
  }
@@ -210,6 +210,10 @@ protocol SGCManager {
210
210
 
211
211
  func sendVoiceActivityDetectionSetting()
212
212
 
213
+ // MARK: - Loudness / Barrier Gate
214
+
215
+ func sendLoudnessGateSetting()
216
+
213
217
  // MARK: - Version Info
214
218
 
215
219
  func requestVersionInfo()
@@ -365,6 +369,10 @@ extension SGCManager {
365
369
 
366
370
  func sendVoiceActivityDetectionSetting() {}
367
371
 
372
+ // MARK: - Loudness / Barrier Gate (default no-op — Mentra Live supports this)
373
+
374
+ func sendLoudnessGateSetting() {}
375
+
368
376
  /// Default no-op; Mentra Live and G2 override to handle phone-detected clock skew.
369
377
  func sendSetSystemTime(_: Int64) {
370
378
  Bridge.log("SGC: sendSetSystemTime not supported")