@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
@@ -177,12 +177,15 @@ class Bridge {
177
177
  _ deviceModel: String,
178
178
  _ deviceName: String,
179
179
  deviceAddress: String = "",
180
- rssi: Int? = nil
180
+ rssi: Int? = nil,
181
+ projectName: String? = nil
181
182
  ) {
182
183
  Task {
183
184
  await MainActor.run {
184
185
  let searchResults = DeviceStore.shared.get("bluetooth", "searchResults") as? [[String: Any]] ?? []
185
- let id = "\(deviceModel):\(deviceName)"
186
+ let id = [deviceModel, projectName?.isEmpty == false ? projectName! : nil, deviceName]
187
+ .compactMap { $0 }
188
+ .joined(separator: ":")
186
189
  var newResult: [String: Any] = [
187
190
  "id": id,
188
191
  "model": deviceModel,
@@ -191,6 +194,9 @@ class Bridge {
191
194
  if !deviceAddress.isEmpty {
192
195
  newResult["address"] = deviceAddress
193
196
  }
197
+ if let projectName, !projectName.isEmpty {
198
+ newResult["projectName"] = projectName
199
+ }
194
200
  if let rssi {
195
201
  newResult["rssi"] = rssi
196
202
  }
@@ -400,7 +406,10 @@ class Bridge {
400
406
  Bridge.sendTypedMessage("glasses_serial_number", body: body)
401
407
  }
402
408
 
403
- static func sendWifiStatusChange(connected: Bool, ssid: String?, localIp: String?) {
409
+ /// `error` is the glasses' provisioning failure reason (e.g. "connect_timeout",
410
+ /// "connected_to_other_network") when this status is the verdict of a failed
411
+ /// connect attempt; nil for routine link-state updates.
412
+ static func sendWifiStatusChange(connected: Bool, ssid: String?, localIp: String?, error: String? = nil) {
404
413
  guard let status = WifiStatus.fromStoreFields(
405
414
  connected: connected,
406
415
  ssid: ssid,
@@ -408,27 +417,78 @@ class Bridge {
408
417
  ) else {
409
418
  return
410
419
  }
411
- Bridge.sendTypedMessage("wifi_status_change", body: status.values)
420
+ var body = status.values
421
+ if let error {
422
+ body["error"] = error
423
+ }
424
+ Bridge.sendTypedMessage("wifi_status_change", body: body)
412
425
  }
413
426
 
414
- static func updateWifiScanResults(_ networks: [[String: Any]], scanComplete: Bool) {
415
- Task {
416
- await MainActor.run {
417
- var storedNetworks: [[String: Any]] =
418
- DeviceStore.shared.get("bluetooth", "wifiScanResults") as? [[String: Any]] ?? []
419
- // add the networks to the storedNetworks array, removing duplicates by ssid
420
- for network in networks {
421
- if !storedNetworks.contains(where: {
422
- $0["ssid"] as? String == network["ssid"] as? String
423
- }) {
424
- storedNetworks.append(network)
427
+ /// Claim the WiFi scan-results store for a newly requested scan. Called by the
428
+ /// SDK when it generates the scanId, BEFORE the scan command goes out: store
429
+ /// ownership is decided at request time, not by whichever chunk arrives first,
430
+ /// so a delayed chunk from an older, abandoned scan can never reset or clobber
431
+ /// the current scan's accumulator.
432
+ @MainActor
433
+ static func claimWifiScanResults(scanId: String) {
434
+ DeviceStore.shared.apply("bluetooth", "wifiScanActiveScanId", scanId)
435
+ }
436
+
437
+ static func updateWifiScanResults(
438
+ _ networks: [[String: Any]],
439
+ scanComplete: Bool,
440
+ scanId: String? = nil
441
+ ) {
442
+ // Correlated scans accumulate chunks until the terminal scan_complete, so
443
+ // chunks must reach the SDK in receive order. A Task per message can reach
444
+ // the MainActor out of creation order; DispatchQueue.main keeps the FIFO
445
+ // order of the serial bluetooth queue that delivers these.
446
+ DispatchQueue.main.async {
447
+ MainActor.assumeIsolated {
448
+ // Only chunks echoing the active scanId claimed at request time may
449
+ // mutate the store; foreign chunks are still forwarded to the SDK
450
+ // sink, which drops stale ids itself. Scan-id-less chunks (old
451
+ // firmware) keep the legacy accumulate-forever store behavior.
452
+ let ownsStore: Bool
453
+ if let scanId {
454
+ ownsStore =
455
+ scanId == DeviceStore.shared.get("bluetooth", "wifiScanActiveScanId") as? String
456
+ } else {
457
+ ownsStore = true
458
+ }
459
+ var updatedNetworks: [[String: Any]] = networks
460
+ if ownsStore {
461
+ var storedNetworks: [[String: Any]] =
462
+ DeviceStore.shared.get("bluetooth", "wifiScanResults") as? [[String: Any]] ?? []
463
+ if let scanId,
464
+ scanId != DeviceStore.shared.get("bluetooth", "wifiScanResultsScanId") as? String
465
+ {
466
+ // First chunk of a new scan: drop networks accumulated for a previous scan
467
+ // so stale entries never carry over into this scan's store.
468
+ storedNetworks = []
469
+ DeviceStore.shared.apply("bluetooth", "wifiScanResultsScanId", scanId)
470
+ }
471
+ // add the networks to the storedNetworks array, removing duplicates by ssid
472
+ for network in networks {
473
+ if !storedNetworks.contains(where: {
474
+ $0["ssid"] as? String == network["ssid"] as? String
475
+ }) {
476
+ storedNetworks.append(network)
477
+ }
425
478
  }
479
+ DeviceStore.shared.apply("bluetooth", "wifiScanResults", storedNetworks)
480
+ updatedNetworks = storedNetworks
426
481
  }
427
- DeviceStore.shared.apply("bluetooth", "wifiScanResults", storedNetworks)
428
- Bridge.sendTypedMessage(
429
- "wifi_scan_result",
430
- body: ["networks": storedNetworks, "scanComplete": scanComplete]
431
- )
482
+ // Correlated scans: the SDK accumulates and dedupes chunks per scanId itself,
483
+ // so forward only this chunk; the merged store list is for UI consumers.
484
+ var body: [String: Any] = [
485
+ "networks": scanId != nil ? networks : updatedNetworks,
486
+ "scanComplete": scanComplete,
487
+ ]
488
+ if let scanId {
489
+ body["scanId"] = scanId
490
+ }
491
+ Bridge.sendTypedMessage("wifi_scan_result", body: body)
432
492
  }
433
493
  }
434
494
  }
@@ -441,7 +501,7 @@ class Bridge {
441
501
  Bridge.sendTypedMessage("mtk_update_complete", body: eventBody)
442
502
  }
443
503
 
444
- /// Send ota_start_ack — glasses confirmed receipt of ota_start command
504
+ /// Send ota_start_ack —glasses confirmed receipt of ota_start command
445
505
  static func sendOtaStartAck() {
446
506
  let eventBody: [String: Any] = [
447
507
  "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
@@ -553,3 +613,9 @@ class Bridge {
553
613
  return payload
554
614
  }
555
615
  }
616
+
617
+
618
+
619
+
620
+
621
+
@@ -22,7 +22,7 @@ struct ViewState {
22
22
  var text: String
23
23
  var data: String?
24
24
  var animationData: [String: Any]?
25
- // Optional container position/size used by bitmap_view and positioned_text (G2; ignored by others)
25
+ // Optional container position/size - used by bitmap_view and positioned_text (G2; ignored by others)
26
26
  var bmpX: Int32? = nil
27
27
  var bmpY: Int32? = nil
28
28
  var bmpWidth: Int32? = nil
@@ -76,7 +76,7 @@ struct ViewState {
76
76
 
77
77
  if isPaired {
78
78
  // Device is paired! Don't activate it - let PhoneMic.swift activate when recording starts
79
- Bridge.log("Audio: Mentra Live is paired (preserving A2DP for music)")
79
+ Bridge.log("Audio: Mentra Live is paired (preserving A2DP for music)")
80
80
  glassesBluetoothClassicConnected = true
81
81
  } else {
82
82
  glassesBluetoothClassicConnected = false
@@ -88,7 +88,7 @@ struct ViewState {
88
88
  guard let self = self else { return }
89
89
 
90
90
  if connected {
91
- Bridge.log("Audio: Device paired and connected")
91
+ Bridge.log("Audio: Device paired and connected")
92
92
  // Don't activate - let PhoneMic.swift handle that when recording starts
93
93
  self.glassesBluetoothClassicConnected = true
94
94
  } else {
@@ -320,7 +320,7 @@ struct ViewState {
320
320
  ),
321
321
  ]
322
322
 
323
- // Scene slots one whole SceneFrame per view (main/dashboard), parallel to
323
+ // Scene slots - one whole SceneFrame per view (main/dashboard), parallel to
324
324
  // viewStates. When a slot holds a scene, viewStates carries a "scene"
325
325
  // sentinel so sendCurrentState routes here. Holding the WHOLE frame keeps
326
326
  // native re-dispatch coherent (dashboard exit re-applies a complete scene).
@@ -417,6 +417,10 @@ struct ViewState {
417
417
  handlePcm(pcmData)
418
418
  }
419
419
 
420
+ func reportGlassesAudioActivity() {
421
+ lastLc3Event = Date()
422
+ }
423
+
420
424
  func handlePcm(_ pcmData: Data) {
421
425
  handleSendingPcm(pcmData)
422
426
 
@@ -626,6 +630,8 @@ struct ViewState {
626
630
  sgc = MentraLive()
627
631
  } else if wearable.contains(DeviceTypes.NIMO) {
628
632
  sgc = Nimo()
633
+ } else if wearable.contains(DeviceTypes.AR99) {
634
+ sgc = Ar99()
629
635
  } else if wearable.contains(DeviceTypes.FRAME) {
630
636
  // sgc = FrameManager()
631
637
  }
@@ -722,7 +728,7 @@ struct ViewState {
722
728
  )
723
729
  case "positioned_text":
724
730
  Bridge.log(
725
- "MAN: positioned_text text='\(currentViewState.text)' rect=\(currentViewState.bmpX ?? 0),\(currentViewState.bmpY ?? 0) \(currentViewState.bmpWidth ?? 576)x\(currentViewState.bmpHeight ?? 288)"
731
+ "MAN: positioned_text -> text='\(currentViewState.text)' rect=\(currentViewState.bmpX ?? 0),\(currentViewState.bmpY ?? 0) \(currentViewState.bmpWidth ?? 576)x\(currentViewState.bmpHeight ?? 288)"
726
732
  )
727
733
  await sgc?.sendPositionedText(
728
734
  currentViewState.text,
@@ -799,6 +805,16 @@ struct ViewState {
799
805
  return
800
806
  }
801
807
 
808
+ if sgc?.isMicSuspendedForAudio == true {
809
+ Bridge.log("MAN: Glasses mic intentionally suspended for phone audio; skipping mic recovery")
810
+ return
811
+ }
812
+
813
+ if PhoneAudioMonitor.getInstance().isOwnAppAudioPlaying() {
814
+ Bridge.log("MAN: Mentra audio is playing; skipping glasses mic recovery")
815
+ return
816
+ }
817
+
802
818
  let timeSinceLastLc3Event = Date().timeIntervalSince(lastLc3Event ?? Date())
803
819
  if timeSinceLastLc3Event > 5 {
804
820
  Bridge.log("MAN: No audio activity in the last 5 seconds from glasses, reinitializing glasses mic")
@@ -856,7 +872,7 @@ struct ViewState {
856
872
  let deviceName = session.availableInputs?.first(where: {
857
873
  $0.portName.localizedCaseInsensitiveContains(audioDevicePattern)
858
874
  })?.portName
859
- Bridge.log("MAN: Successfully detected newly paired device '\(deviceName)'")
875
+ Bridge.log("MAN: Successfully detected newly paired device '\(deviceName)'")
860
876
  glassesBluetoothClassicConnected = true
861
877
  } else {
862
878
  glassesBluetoothClassicConnected = false
@@ -923,7 +939,7 @@ struct ViewState {
923
939
  // stale/torn-down connection.
924
940
  guard let sgc = self?.sgc else { return }
925
941
  let h = DeviceStore.shared.get("bluetooth", "dashboard_height") as? Int ?? 4
926
- // Fall back to the canonical default (2), matching DeviceStore not 1.
942
+ // Fall back to the canonical default (2), matching DeviceStore, not 1.
927
943
  let rawDepth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 2
928
944
  let d = min(max(rawDepth, 1), 4)
929
945
  sgc.setDashboardPosition(h, d)
@@ -957,6 +973,10 @@ struct ViewState {
957
973
  Bridge.saveSetting("default_wearable", defaultWearable)
958
974
  Bridge.saveSetting("device_name", deviceName)
959
975
  Bridge.saveSetting("device_address", deviceAddress)
976
+ if defaultWearable.contains(DeviceTypes.AR99) {
977
+ let projectName = (DeviceStore.shared.get("bluetooth", "project_name") as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
978
+ Bridge.saveSetting("project_name", projectName)
979
+ }
960
980
 
961
981
  }
962
982
 
@@ -1163,7 +1183,7 @@ struct ViewState {
1163
1183
  // Legacy→scene handoff: stale legacy content (e.g. a cloud app's
1164
1184
  // text wall) must not linger under the scene's elements.
1165
1185
  // clearDisplay is the per-device "wipe what's there" (blank-in-place
1166
- // on G2 no page rebuild).
1186
+ // on G2 - no page rebuild).
1167
1187
  let prevLegacyType = viewStates[stateIndex].layoutType
1168
1188
  if !prevLegacyType.isEmpty, prevLegacyType != "clear_view", prevLegacyType != "scene" {
1169
1189
  sgc?.clearDisplay()
@@ -1182,7 +1202,7 @@ struct ViewState {
1182
1202
  }
1183
1203
 
1184
1204
  // Store the REDISPATCH form: any later sendCurrentState (dashboard
1185
- // exit, head-up return) must repaint the whole frame the original
1205
+ // exit, head-up return) must repaint the whole frame - the original
1186
1206
  // annotations are only valid for the first dispatch right now.
1187
1207
  sceneStates[stateIndex] = frame.asReplay()
1188
1208
  viewStates[stateIndex] = ViewState(
@@ -1196,7 +1216,7 @@ struct ViewState {
1196
1216
  }
1197
1217
  }
1198
1218
 
1199
- /// Guarded scene dispatch mirrors sendCurrentState's send conditions.
1219
+ /// Guarded scene dispatch - mirrors sendCurrentState's send conditions.
1200
1220
  private func dispatchSceneFrame(_ frame: SceneFrame) {
1201
1221
  if screenDisabled { return }
1202
1222
  if sgc?.type.contains(DeviceTypes.SIMULATED) ?? true { return }
@@ -1276,10 +1296,10 @@ struct ViewState {
1276
1296
  sgc?.sendStreamKeepAlive(message)
1277
1297
  }
1278
1298
 
1279
- func requestWifiScan() {
1299
+ func requestWifiScan(scanId: String? = nil) {
1280
1300
  Bridge.log("MAN: Requesting wifi scan")
1281
1301
  DeviceStore.shared.apply("bluetooth", "wifiScanResults", [])
1282
- sgc?.requestWifiScan()
1302
+ sgc?.requestWifiScan(scanId: scanId)
1283
1303
  }
1284
1304
 
1285
1305
  func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) {
@@ -1325,6 +1345,24 @@ struct ViewState {
1325
1345
  (sgc as? MentraLive)?.sendOtaQueryStatus()
1326
1346
  }
1327
1347
 
1348
+ func startAr99OtaFromFile(_ path: String) throws -> Bool {
1349
+ guard let ar99 = sgc as? Ar99 else {
1350
+ throw BluetoothSdkError(code: "unsupported_device", message: "This command requires AR99 glasses.")
1351
+ }
1352
+ return ar99.startOtaFromFile(path)
1353
+ }
1354
+
1355
+ func cancelAr99Ota() {
1356
+ (sgc as? Ar99)?.cancelAr99Ota()
1357
+ }
1358
+
1359
+ func sendAr99FactoryReset() throws {
1360
+ guard let ar99 = sgc as? Ar99 else {
1361
+ throw BluetoothSdkError(code: "unsupported_device", message: "This command requires AR99 glasses.")
1362
+ }
1363
+ ar99.sendFactoryReset()
1364
+ }
1365
+
1328
1366
  private func liveSgc() throws -> MentraLive {
1329
1367
  guard let live = sgc as? MentraLive else {
1330
1368
  throw BluetoothSdkError(code: "unsupported_device", message: "This command requires Mentra Live glasses.")
@@ -1359,6 +1397,42 @@ struct ViewState {
1359
1397
  try liveSgc().sendCameraFovSetting(requestId: requestId, fov: fov, roiPosition: roiPosition)
1360
1398
  }
1361
1399
 
1400
+ func sendLegacyCameraFovSetting(fov: Int, roiPosition: Int) throws {
1401
+ let glassesConnected = DeviceStore.shared.get("glasses", "connected") as? Bool ?? false
1402
+ guard glassesConnected else {
1403
+ throw BluetoothSdkError(code: "not_connected", message: "Mentra Live glasses are not connected.")
1404
+ }
1405
+ try liveSgc().sendCameraFovSetting(requestId: nil, fov: fov, roiPosition: roiPosition)
1406
+ }
1407
+
1408
+ func restoreLegacyCameraFovSetting() throws {
1409
+ let glassesConnected = DeviceStore.shared.get("glasses", "connected") as? Bool ?? false
1410
+ guard glassesConnected else {
1411
+ throw BluetoothSdkError(code: "not_connected", message: "Mentra Live glasses are not connected.")
1412
+ }
1413
+ try liveSgc().sendCameraFovSetting()
1414
+ }
1415
+
1416
+ func sendCameraFovOverride(
1417
+ requestId: String,
1418
+ leaseId: String,
1419
+ fov: Int,
1420
+ roiPosition: Int,
1421
+ ttlMs: Int
1422
+ ) throws {
1423
+ try liveSgc().sendCameraFovOverride(
1424
+ requestId: requestId,
1425
+ leaseId: leaseId,
1426
+ fov: fov,
1427
+ roiPosition: roiPosition,
1428
+ ttlMs: ttlMs
1429
+ )
1430
+ }
1431
+
1432
+ func releaseCameraFovOverride(requestId: String, leaseId: String) throws {
1433
+ try liveSgc().releaseCameraFovOverride(requestId: requestId, leaseId: leaseId)
1434
+ }
1435
+
1362
1436
  func sendCameraTuningConfig(requestId: String, anrOn: Bool, gainOn: Bool) throws {
1363
1437
  try liveSgc().sendCameraTuningConfig(requestId: requestId, anrOn: anrOn, gainOn: gainOn)
1364
1438
  }
@@ -1366,8 +1440,11 @@ struct ViewState {
1366
1440
  func warmUpCamera(
1367
1441
  requestId: String,
1368
1442
  size: PhotoSize,
1443
+ mode: PhotoMode = .photo,
1369
1444
  exposureTimeNs: Double?,
1370
- durationMs: Int
1445
+ durationMs: Int,
1446
+ zsl: Bool? = nil,
1447
+ mfnr: Bool? = nil
1371
1448
  ) throws {
1372
1449
  guard let live = sgc as? MentraLive else {
1373
1450
  // Fail fast like other camera commands so the SDK promise rejects immediately instead
@@ -1378,11 +1455,18 @@ struct ViewState {
1378
1455
  live.warmUpCamera(
1379
1456
  requestId: requestId,
1380
1457
  size: size,
1458
+ mode: mode,
1381
1459
  exposureTimeNs: exposureTimeNs,
1382
- durationMs: durationMs
1460
+ durationMs: durationMs,
1461
+ zsl: zsl,
1462
+ mfnr: mfnr
1383
1463
  )
1384
1464
  }
1385
1465
 
1466
+ func stopCameraWarmUp(requestId: String) throws {
1467
+ try liveSgc().stopCameraWarmUp(requestId: requestId)
1468
+ }
1469
+
1386
1470
  /// Request version info from glasses.
1387
1471
  /// Glasses will respond with version_info message containing build number, firmware version, etc.
1388
1472
  func requestVersionInfo() {
@@ -1451,7 +1535,7 @@ struct ViewState {
1451
1535
  )
1452
1536
  }
1453
1537
 
1454
- /// Mentra Live only: K900 `cs_getvol` / `sr_getvol` (step volume 0–15).
1538
+ /// Mentra Live only: K900 `cs_getvol` / `sr_getvol` (step volume 0-5).
1455
1539
  func getGlassesMediaVolume() async throws -> [String: Any] {
1456
1540
  guard let live = sgc as? MentraLive else {
1457
1541
  throw NSError(
@@ -1506,14 +1590,15 @@ struct ViewState {
1506
1590
  zsl: request.zsl,
1507
1591
  ispDigitalGain: request.ispDigitalGain,
1508
1592
  ispAnalogGain: request.ispAnalogGain,
1509
- mode: request.mode
1593
+ mode: request.mode,
1594
+ transferMethod: request.transferMethod
1510
1595
  )
1511
1596
  Bridge.log(
1512
1597
  "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=\(routed.requestId) webhookUrl=\(routed.webhookUrl ?? "nil") size=\(routed.size.rawValue) compress=\(routed.compress?.rawValue ?? "none") save=\(routed.save) sound=\(routed.sound) exposureTimeNs=\(manualExposureNs.map { String($0) } ?? "nil") iso=\(manualIso.map { String($0) } ?? "auto") aeDivisor=\(routed.aeExposureDivisor.map { String($0) } ?? "nil") isoCap=\(routed.isoCap.map { String($0) } ?? "nil") sgc=\(sgc != nil ? String(describing: type(of: sgc!)) : "null")"
1513
1598
  )
1514
1599
  guard let sgc else {
1515
1600
  Bridge.log(
1516
- "MAN: PHOTO PIPELINE sgc is null (glasses not connected); dropping requestId=\(routed.requestId)"
1601
+ "MAN: PHOTO PIPELINE - sgc is null (glasses not connected); dropping requestId=\(routed.requestId)"
1517
1602
  )
1518
1603
  return
1519
1604
  }
@@ -1525,13 +1610,19 @@ struct ViewState {
1525
1610
  Bridge.log("MAN: No default wearable, returning")
1526
1611
  return
1527
1612
  }
1528
- if deviceName.isEmpty {
1529
- Bridge.log("MAN: No device name, returning")
1613
+ let reconnectTarget =
1614
+ if defaultWearable.contains(DeviceTypes.AR99), !deviceAddress.isEmpty {
1615
+ deviceAddress
1616
+ } else {
1617
+ deviceName
1618
+ }
1619
+ if reconnectTarget.isEmpty {
1620
+ Bridge.log("MAN: No reconnect target, returning")
1530
1621
  return
1531
1622
  }
1532
1623
  initSGC(defaultWearable)
1533
1624
  searching = true
1534
- sgc?.connectById(deviceName)
1625
+ sgc?.connectById(reconnectTarget)
1535
1626
  connectDefaultController()
1536
1627
  }
1537
1628
 
@@ -1621,6 +1712,11 @@ struct ViewState {
1621
1712
  shouldSendBootingMessage = true // Reset for next first connect
1622
1713
  // clear glasses properties:
1623
1714
  DeviceStore.shared.apply("glasses", "deviceModel", "")
1715
+ // A manufacturing serial is session-bound. Clear it on every disconnect so a
1716
+ // previously connected pair's serial can never be reported for the next
1717
+ // connection (e.g. switching from G1/Ar99, which populate it from the
1718
+ // advertisement, to a model that never writes it, like G2).
1719
+ DeviceStore.shared.apply("glasses", "serialNumber", "")
1624
1720
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
1625
1721
  DeviceStore.shared.apply("glasses", "connected", false)
1626
1722
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
@@ -1652,6 +1748,7 @@ struct ViewState {
1652
1748
  Bridge.saveSetting("default_wearable", "")
1653
1749
  Bridge.saveSetting("device_name", "")
1654
1750
  Bridge.saveSetting("device_address", "")
1751
+ Bridge.saveSetting("project_name", "")
1655
1752
  }
1656
1753
 
1657
1754
  func forgetController() {
@@ -1710,3 +1807,4 @@ struct ViewState {
1710
1807
  cancellables.removeAll()
1711
1808
  }
1712
1809
  }
1810
+
@@ -88,6 +88,7 @@ class DeviceStore {
88
88
  store.set("bluetooth", "contextual_dashboard", true)
89
89
  store.set("bluetooth", "gallery_mode", true)
90
90
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
91
+ store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.loudnessGateEnabled)
91
92
  // Mentra Nex feature flag (off by default; toggled from Nex Developer Settings):
92
93
  store.set("bluetooth", "nex_chinese_captions", false)
93
94
  store.set("bluetooth", "screen_disabled", false)
@@ -245,6 +246,9 @@ class DeviceStore {
245
246
  case ("bluetooth", "voice_activity_detection_enabled"):
246
247
  DeviceManager.shared.sgc?.sendVoiceActivityDetectionSetting()
247
248
 
249
+ case ("bluetooth", "loudness_gate_enabled"):
250
+ DeviceManager.shared.sgc?.sendLoudnessGateSetting()
251
+
248
252
  case ("bluetooth", "screen_disabled"):
249
253
  if let disabled = value as? Bool {
250
254
  if disabled {