@mentra/bluetooth-sdk 0.1.20 → 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 (73) hide show
  1. package/README.md +5 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -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 +3 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  19. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
  22. package/build/BluetoothSdk.types.d.ts +46 -1
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_internal.d.ts +2 -0
  26. package/build/_internal.d.ts.map +1 -1
  27. package/build/_internal.js +2 -0
  28. package/build/_internal.js.map +1 -1
  29. package/build/_private/BluetoothSdkModule.d.ts +23 -2
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +20 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  34. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  35. package/build/_private/MentraLocalNetworkModule.js +3 -0
  36. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  37. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  38. package/build/_private/cameraRequestPayload.js +1 -0
  39. package/build/_private/cameraRequestPayload.js.map +1 -1
  40. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  41. package/build/_private/photoRequestPayload.js +1 -0
  42. package/build/_private/photoRequestPayload.js.map +1 -1
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +4 -0
  45. package/build/index.js.map +1 -1
  46. package/expo-module.config.json +1 -0
  47. package/ios/BluetoothSdkModule.swift +72 -0
  48. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  49. package/ios/Source/Bridge.swift +72 -18
  50. package/ios/Source/DeviceManager.swift +51 -3
  51. package/ios/Source/DeviceStore.swift +1 -1
  52. package/ios/Source/MentraBluetoothSDK.swift +289 -25
  53. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  54. package/ios/Source/camera/CameraModels.swift +8 -3
  55. package/ios/Source/sgcs/G1.swift +1 -1
  56. package/ios/Source/sgcs/G2.swift +19 -7
  57. package/ios/Source/sgcs/Mach1.swift +1 -1
  58. package/ios/Source/sgcs/MentraLive.swift +318 -37
  59. package/ios/Source/sgcs/MentraNex.swift +1 -1
  60. package/ios/Source/sgcs/Nimo.swift +1 -1
  61. package/ios/Source/sgcs/SGCManager.swift +5 -2
  62. package/ios/Source/sgcs/Simulated.swift +1 -1
  63. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  64. package/ios/Source/streaming/StreamModels.swift +9 -0
  65. package/ios/Source/tts/TTSTools.swift +1 -1
  66. package/package.json +1 -2
  67. package/src/BluetoothSdk.types.ts +49 -0
  68. package/src/_internal.ts +2 -0
  69. package/src/_private/BluetoothSdkModule.ts +64 -13
  70. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  71. package/src/_private/cameraRequestPayload.ts +1 -0
  72. package/src/_private/photoRequestPayload.ts +2 -3
  73. package/src/index.ts +4 -0
@@ -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
  ) {
@@ -1095,6 +1107,10 @@ extension MentraLive: CBPeripheralDelegate {
1095
1107
  Bridge.log("LIVE: 🎤 Enabled LC3 audio notifications")
1096
1108
  }
1097
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
+
1098
1114
  // Start readiness check loop
1099
1115
  self.startSignalStrengthPolling()
1100
1116
  self.startReadinessCheckLoop()
@@ -1213,6 +1229,7 @@ extension MentraLive: CBPeripheralDelegate {
1213
1229
 
1214
1230
  if uuid == self.RX_CHAR_UUID, isNotifying {
1215
1231
  Bridge.log("LIVE: 🔔 Ready to receive data via notifications")
1232
+ self.enableAncsRelayIfAuthorized()
1216
1233
  }
1217
1234
  }
1218
1235
  }
@@ -1267,14 +1284,12 @@ class MentraLive: NSObject, SGCManager {
1267
1284
  // a previous pairing into the next one (would otherwise surface as wrong overall_percent
1268
1285
  // or stale lastBesOtaProgress on the next OTA).
1269
1286
  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()
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() }
1278
1293
  stopSignalStrengthPolling()
1279
1294
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
1280
1295
  DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
@@ -1410,6 +1425,7 @@ class MentraLive: NSObject, SGCManager {
1410
1425
  // So we temporarily suspend the LC3 mic during phone audio playback
1411
1426
  private var micIntentEnabled = false // User/system WANTS mic enabled
1412
1427
  private var micSuspendedForAudio = false // Mic temporarily suspended due to phone audio
1428
+ var isMicSuspendedForAudio: Bool { micSuspendedForAudio }
1413
1429
  private var phoneAudioMonitor: PhoneAudioMonitor?
1414
1430
 
1415
1431
  // Timing Constants
@@ -1453,10 +1469,24 @@ class MentraLive: NSObject, SGCManager {
1453
1469
  private var peerWireProtocolVersion = 0
1454
1470
  private var useBinaryWireProtocol = false
1455
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
1456
1484
  // Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to legacy
1457
1485
  // big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le (or a
1458
1486
  // v2 binary handshake succeeds, which implies wire-v2 LE).
1459
1487
  private var peerK900Le = false
1488
+ private var ancsAppIdentifiers: [UInt32: String] = [:]
1489
+ private var ancsRelayEnableRequested = false
1460
1490
  private var peerWireCapsBinary = false
1461
1491
  private var peerFilePayloadV2 = false
1462
1492
  private var globalMessageId = 0
@@ -1705,7 +1735,7 @@ class MentraLive: NSObject, SGCManager {
1705
1735
  let bleImgId =
1706
1736
  "I" + String(format: "%09d", Int(Date().timeIntervalSince1970 * 1000) % 100_000_000)
1707
1737
  json["bleImgId"] = bleImgId
1708
- json["transferMethod"] = "auto"
1738
+ json["transferMethod"] = request.transferMethod
1709
1739
 
1710
1740
  if let webhookUrl = request.webhookUrl, !webhookUrl.isEmpty {
1711
1741
  json["webhookUrl"] = webhookUrl
@@ -1746,7 +1776,9 @@ class MentraLive: NSObject, SGCManager {
1746
1776
  }
1747
1777
  request.appendScanFields(to: &json)
1748
1778
 
1749
- 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
+ )
1750
1782
  Bridge.log("LIVE: PHOTO PIPELINE [6/6] Dispatching take_photo to sendJson()")
1751
1783
 
1752
1784
  sendJson(json, wakeUp: true)
@@ -1755,11 +1787,12 @@ class MentraLive: NSObject, SGCManager {
1755
1787
  func warmUpCamera(
1756
1788
  requestId: String,
1757
1789
  size: PhotoSize,
1790
+ mode: PhotoMode = .photo,
1758
1791
  exposureTimeNs: Double?,
1759
1792
  durationMs: Int
1760
1793
  ) {
1761
1794
  Bridge.log(
1762
- "LIVE: warmUpCamera() entry requestId=\(requestId) size=\(size.rawValue) durationMs=\(durationMs)"
1795
+ "LIVE: warmUpCamera() entry requestId=\(requestId) size=\(size.rawValue) mode=\(mode.rawValue) durationMs=\(durationMs)"
1763
1796
  )
1764
1797
 
1765
1798
  let allowedSizes = ["low", "medium", "high", "max"]
@@ -1768,6 +1801,7 @@ class MentraLive: NSObject, SGCManager {
1768
1801
  "type": "camera_warm_up",
1769
1802
  "requestId": requestId,
1770
1803
  "size": allowedSizes.contains(sizeRaw) ? sizeRaw : "medium",
1804
+ "mode": mode.rawValue,
1771
1805
  "durationMs": durationMs > 0 ? durationMs : 15000,
1772
1806
  ]
1773
1807
 
@@ -1778,6 +1812,13 @@ class MentraLive: NSObject, SGCManager {
1778
1812
  sendJson(json, wakeUp: true)
1779
1813
  }
1780
1814
 
1815
+ func stopCameraWarmUp(requestId: String) {
1816
+ sendJson(
1817
+ ["type": "camera_warm_up_stop", "requestId": requestId],
1818
+ wakeUp: true
1819
+ )
1820
+ }
1821
+
1781
1822
  func startStream(_ message: [String: Any]) {
1782
1823
  Bridge.log("Starting stream")
1783
1824
  var json = message
@@ -1819,17 +1860,24 @@ class MentraLive: NSObject, SGCManager {
1819
1860
  // MARK: - Command Queue
1820
1861
 
1821
1862
  class PendingMessage {
1822
- init(data: Data, id: String, retries: Int, trace: BleWriteTrace? = nil) {
1863
+ init(data: Data, id: String, retries: Int, trace: BleWriteTrace? = nil, generation: Int = 0) {
1823
1864
  self.data = data
1824
1865
  self.id = id
1825
1866
  self.retries = retries
1826
1867
  self.trace = trace
1868
+ self.generation = generation
1827
1869
  }
1828
1870
 
1829
1871
  let data: Data
1830
1872
  let retries: Int
1831
1873
  let id: String
1832
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
1833
1881
  }
1834
1882
 
1835
1883
  struct OutgoingBleCommandTraceInfo {
@@ -1875,6 +1923,10 @@ class MentraLive: NSObject, SGCManager {
1875
1923
  guard !commands.isEmpty else { return nil }
1876
1924
  return commands.removeFirst()
1877
1925
  }
1926
+
1927
+ func removeAll() {
1928
+ commands.removeAll()
1929
+ }
1878
1930
  }
1879
1931
 
1880
1932
  private func setupCommandQueue() {
@@ -1884,7 +1936,12 @@ class MentraLive: NSObject, SGCManager {
1884
1936
  let pendingIsNil = await MainActor.run { self.pending == nil }
1885
1937
  if pendingIsNil {
1886
1938
  if let command = await self.commandQueue.dequeue() {
1887
- 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
+ }
1888
1945
  }
1889
1946
  }
1890
1947
  try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
@@ -1960,7 +2017,8 @@ class MentraLive: NSObject, SGCManager {
1960
2017
  data: pendingMessage.data,
1961
2018
  id: pendingMessage.id,
1962
2019
  retries: pendingMessage.retries + 1,
1963
- trace: pendingMessage.trace
2020
+ trace: pendingMessage.trace,
2021
+ generation: pendingMessage.generation
1964
2022
  )
1965
2023
 
1966
2024
  // Push to front of queue for immediate retry
@@ -2054,7 +2112,36 @@ class MentraLive: NSObject, SGCManager {
2054
2112
  // Set connection timeout
2055
2113
  startConnectionTimeout()
2056
2114
 
2057
- 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
+ }
2058
2145
  }
2059
2146
 
2060
2147
  private func handleReconnection() {
@@ -2320,7 +2407,14 @@ class MentraLive: NSObject, SGCManager {
2320
2407
 
2321
2408
  switch type {
2322
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()
2323
2416
  parsePeerWireCaps(json)
2417
+ maybeSendWireHandshake()
2324
2418
  handleGlassesReady()
2325
2419
 
2326
2420
  case "battery_status":
@@ -2355,7 +2449,7 @@ class MentraLive: NSObject, SGCManager {
2355
2449
  } else if connected {
2356
2450
  DeviceStore.shared.apply("glasses", "wifiError", "")
2357
2451
  }
2358
- updateWifiStatus(connected: connected, ssid: ssid, ip: ip)
2452
+ updateWifiStatus(connected: connected, ssid: ssid, ip: ip, error: wifiError.isEmpty ? nil : wifiError)
2359
2453
 
2360
2454
  case "hotspot_status_update":
2361
2455
  let enabled = json["hotspot_enabled"] as? Bool ?? false
@@ -2599,8 +2693,6 @@ class MentraLive: NSObject, SGCManager {
2599
2693
  if let buildNumber = fields["build_number"] as? String {
2600
2694
  isNewVersion = (Int(buildNumber) ?? 0) >= 5
2601
2695
  DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
2602
- parsePeerWireCaps(json)
2603
- maybeSendWireHandshake()
2604
2696
  }
2605
2697
  if let deviceModel = fields["device_model"] as? String {
2606
2698
  DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
@@ -2632,6 +2724,16 @@ class MentraLive: NSObject, SGCManager {
2632
2724
  DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2633
2725
  }
2634
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
+
2635
2737
  Bridge.sendVersionInfo(fields)
2636
2738
  } else {
2637
2739
  Bridge.log("Unhandled message type: \(type)")
@@ -2729,6 +2831,11 @@ class MentraLive: NSObject, SGCManager {
2729
2831
  }
2730
2832
 
2731
2833
  switch command {
2834
+ case "sr_ancs":
2835
+ if let body = k900ParseBody(json["B"]) {
2836
+ processAncsRelay(body)
2837
+ }
2838
+
2732
2839
  case "sr_hrt":
2733
2840
  if let bodyObj = json["B"] as? [String: Any] {
2734
2841
  let readyResponse = bodyObj["ready"] as? Int ?? 0
@@ -2885,7 +2992,17 @@ class MentraLive: NSObject, SGCManager {
2885
2992
 
2886
2993
  let syntheticStatus: String
2887
2994
  if besOtaStatus == "FINISHED" {
2888
- 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"
2889
3006
  } else if besOtaStatus == "FAILED" {
2890
3007
  syntheticStatus = "failed"
2891
3008
  } else {
@@ -2933,11 +3050,87 @@ class MentraLive: NSObject, SGCManager {
2933
3050
  }
2934
3051
  }
2935
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
+
2936
3126
  // commands to send to the glasses:
2937
3127
 
2938
- func requestWifiScan() {
3128
+ func requestWifiScan(scanId: String?) {
2939
3129
  Bridge.log("LIVE: Requesting WiFi scan from glasses")
2940
- 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
+ }
2941
3134
  sendJson(json, wakeUp: true)
2942
3135
  }
2943
3136
 
@@ -3177,7 +3370,8 @@ class MentraLive: NSObject, SGCManager {
3177
3370
  }
3178
3371
 
3179
3372
  let scanComplete = json["scan_complete"] as? Bool ?? json["scanComplete"] as? Bool ?? false
3180
- 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)
3181
3375
  }
3182
3376
 
3183
3377
  private func handleButtonPress(_ json: [String: Any]) {
@@ -3861,8 +4055,12 @@ class MentraLive: NSObject, SGCManager {
3861
4055
 
3862
4056
  func queueSend(_ data: Data, id: String, trace: BleWriteTrace?) {
3863
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
3864
4062
  Task {
3865
- 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))
3866
4064
  guard trace != nil, queueSize >= significantQueueSize else {
3867
4065
  return
3868
4066
  }
@@ -4092,19 +4290,47 @@ class MentraLive: NSObject, SGCManager {
4092
4290
  }
4093
4291
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
4094
4292
  wireHandshakeQueued = true
4293
+ wireHandshakeSentGeneration = wireSessionGeneration
4095
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
+ }
4096
4317
  }
4097
4318
 
4098
4319
  private func activateBinaryWireV2Session(logMessage: String) {
4099
4320
  peerWireProtocolVersion = BleWireProtocol.protocolV2
4100
4321
  useBinaryWireProtocol = true
4101
4322
  wireHandshakeQueued = false
4323
+ wireHandshakeAttempts = 0
4102
4324
  peerK900Le = true
4103
4325
  BleJsonCompact.markSessionConnected(epochMs: Int64(Date().timeIntervalSince1970 * 1000))
4104
4326
  Bridge.log(logMessage)
4105
4327
  }
4106
4328
 
4107
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
+ }
4108
4334
  activateBinaryWireV2Session(logMessage: "LIVE: Peer confirmed BLE wire protocol v2")
4109
4335
  }
4110
4336
 
@@ -4624,12 +4850,12 @@ class MentraLive: NSObject, SGCManager {
4624
4850
  }
4625
4851
  }
4626
4852
 
4627
- private func updateWifiStatus(connected: Bool, ssid: String, ip: String) {
4853
+ private func updateWifiStatus(connected: Bool, ssid: String, ip: String, error: String? = nil) {
4628
4854
  Bridge.log("LIVE: 🌐 Updating WiFi status - connected: \(connected), ssid: \(ssid)")
4629
4855
  DeviceStore.shared.apply("glasses", "wifiConnected", connected)
4630
4856
  DeviceStore.shared.apply("glasses", "wifiSsid", ssid)
4631
4857
  DeviceStore.shared.apply("glasses", "wifiLocalIp", ip)
4632
- emitWifiStatusChange()
4858
+ emitWifiStatusChange(error: error)
4633
4859
  }
4634
4860
 
4635
4861
  private func updateHotspotStatus(enabled: Bool, ssid: String, password: String, ip: String) {
@@ -4923,8 +5149,8 @@ class MentraLive: NSObject, SGCManager {
4923
5149
  // emitEvent("BatteryLevelEvent", body: eventBody)
4924
5150
  // }
4925
5151
 
4926
- private func emitWifiStatusChange() {
4927
- 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)
4928
5154
  }
4929
5155
 
4930
5156
  private func emitHotspotStatusChange() {
@@ -5006,14 +5232,8 @@ class MentraLive: NSObject, SGCManager {
5006
5232
 
5007
5233
  // Stop all timers
5008
5234
  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()
5235
+ resetWireNegotiationState()
5236
+ Task { await commandQueue.removeAll() } // stale writes die with the session
5017
5237
  closeL2capFileChannel()
5018
5238
 
5019
5239
  // Disconnect BLE
@@ -5106,6 +5326,34 @@ extension MentraLive {
5106
5326
  * per-link endianness and binary support. Missing wire_caps leaves the legacy defaults (BE, no
5107
5327
  * binary) untouched so older glasses keep working.
5108
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
+
5109
5357
  private func parsePeerWireCaps(_ json: [String: Any]) {
5110
5358
  guard let caps = json["wire_caps"] as? [String: Any] else { return }
5111
5359
  if (caps["k900_le"] as? Bool) == true {
@@ -6000,8 +6248,8 @@ extension MentraLive {
6000
6248
  }
6001
6249
 
6002
6250
  func sendCameraFovSetting() {
6003
- let settings = DeviceStore.shared.get("bluetooth", "camera_fov") as? [String: Any] ?? ["fov": 118, "roi_position": 0]
6004
- 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
6005
6253
  let roiPosition = settings["roi_position"] as? Int ?? 0
6006
6254
  sendCameraFovSetting(requestId: nil, fov: fov, roiPosition: roiPosition)
6007
6255
  }
@@ -6027,6 +6275,39 @@ extension MentraLive {
6027
6275
  sendJson(json, wakeUp: true)
6028
6276
  }
6029
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
+
6030
6311
  func sendCameraTuningConfig(requestId: String?, anrOn: Bool, gainOn: Bool) {
6031
6312
  Bridge.log("Sending camera tuning config: anr=\(anrOn), gain=\(gainOn)")
6032
6313
 
@@ -376,7 +376,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
376
376
  func dbg1() {}
377
377
  func dbg2() {}
378
378
 
379
- func requestWifiScan() {}
379
+ func requestWifiScan(scanId _: String?) {}
380
380
 
381
381
  func sendWifiCredentials(_: String, _: String) {}
382
382
 
@@ -1066,7 +1066,7 @@ class Nimo: NSObject, SGCManager {
1066
1066
 
1067
1067
  // MARK: - SGCManager: Network (no WiFi)
1068
1068
 
1069
- func requestWifiScan() {}
1069
+ func requestWifiScan(scanId _: String?) {}
1070
1070
  func sendWifiCredentials(_: String, _: String) {}
1071
1071
  func forgetWifiNetwork(_: String) {}
1072
1072
  func sendHotspotState(_: Bool) {}
@@ -54,6 +54,7 @@ protocol SGCManager {
54
54
  // MARK: - Audio Control
55
55
 
56
56
  func setMicEnabled(_ enabled: Bool)
57
+ var isMicSuspendedForAudio: Bool { get }
57
58
  func sortMicRanking(list: [String]) -> [String]
58
59
 
59
60
  // MARK: - Messaging
@@ -184,7 +185,7 @@ protocol SGCManager {
184
185
 
185
186
  // MARK: - Network Management
186
187
 
187
- func requestWifiScan()
188
+ func requestWifiScan(scanId: String?)
188
189
  func sendWifiCredentials(_ ssid: String, _ password: String)
189
190
  func forgetWifiNetwork(_ ssid: String)
190
191
  func sendHotspotState(_ enabled: Bool)
@@ -217,6 +218,8 @@ protocol SGCManager {
217
218
  /// doesn't seem to work for concurrency reasons :(
218
219
  /// we can make read-only getters for convienence though:
219
220
  extension SGCManager {
221
+ var isMicSuspendedForAudio: Bool { false }
222
+
220
223
  /// Default: no-op. Only G2 renders positioned text containers; other glasses ignore it.
221
224
  func sendPositionedText(
222
225
  _: String, x _: Int32, y _: Int32, width _: Int32, height _: Int32,
@@ -362,7 +365,7 @@ extension SGCManager {
362
365
 
363
366
  func sendVoiceActivityDetectionSetting() {}
364
367
 
365
- /// Default no-op; Mentra Live overrides when phone detects clock skew during gallery sync.
368
+ /// Default no-op; Mentra Live and G2 override to handle phone-detected clock skew.
366
369
  func sendSetSystemTime(_: Int64) {
367
370
  Bridge.log("SGC: sendSetSystemTime not supported")
368
371
  }
@@ -226,7 +226,7 @@ class Simulated: SGCManager {
226
226
 
227
227
  // MARK: - Network Management
228
228
 
229
- func requestWifiScan() {
229
+ func requestWifiScan(scanId _: String?) {
230
230
  Bridge.log("requestWifiScan")
231
231
  }
232
232