@mentra/bluetooth-sdk 0.1.21-beta.0 → 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 (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 +38 -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 +62 -5
  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
@@ -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
  }
@@ -495,7 +501,7 @@ class Bridge {
495
501
  Bridge.sendTypedMessage("mtk_update_complete", body: eventBody)
496
502
  }
497
503
 
498
- /// Send ota_start_ack — glasses confirmed receipt of ota_start command
504
+ /// Send ota_start_ack —glasses confirmed receipt of ota_start command
499
505
  static func sendOtaStartAck() {
500
506
  let eventBody: [String: Any] = [
501
507
  "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
@@ -607,3 +613,9 @@ class Bridge {
607
613
  return payload
608
614
  }
609
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,
@@ -804,6 +810,11 @@ struct ViewState {
804
810
  return
805
811
  }
806
812
 
813
+ if PhoneAudioMonitor.getInstance().isOwnAppAudioPlaying() {
814
+ Bridge.log("MAN: Mentra audio is playing; skipping glasses mic recovery")
815
+ return
816
+ }
817
+
807
818
  let timeSinceLastLc3Event = Date().timeIntervalSince(lastLc3Event ?? Date())
808
819
  if timeSinceLastLc3Event > 5 {
809
820
  Bridge.log("MAN: No audio activity in the last 5 seconds from glasses, reinitializing glasses mic")
@@ -861,7 +872,7 @@ struct ViewState {
861
872
  let deviceName = session.availableInputs?.first(where: {
862
873
  $0.portName.localizedCaseInsensitiveContains(audioDevicePattern)
863
874
  })?.portName
864
- Bridge.log("MAN: ✅ Successfully detected newly paired device '\(deviceName)'")
875
+ Bridge.log("MAN: Successfully detected newly paired device '\(deviceName)'")
865
876
  glassesBluetoothClassicConnected = true
866
877
  } else {
867
878
  glassesBluetoothClassicConnected = false
@@ -928,7 +939,7 @@ struct ViewState {
928
939
  // stale/torn-down connection.
929
940
  guard let sgc = self?.sgc else { return }
930
941
  let h = DeviceStore.shared.get("bluetooth", "dashboard_height") as? Int ?? 4
931
- // Fall back to the canonical default (2), matching DeviceStore — not 1.
942
+ // Fall back to the canonical default (2), matching DeviceStore, not 1.
932
943
  let rawDepth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 2
933
944
  let d = min(max(rawDepth, 1), 4)
934
945
  sgc.setDashboardPosition(h, d)
@@ -962,6 +973,10 @@ struct ViewState {
962
973
  Bridge.saveSetting("default_wearable", defaultWearable)
963
974
  Bridge.saveSetting("device_name", deviceName)
964
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
+ }
965
980
 
966
981
  }
967
982
 
@@ -1168,7 +1183,7 @@ struct ViewState {
1168
1183
  // Legacy→scene handoff: stale legacy content (e.g. a cloud app's
1169
1184
  // text wall) must not linger under the scene's elements.
1170
1185
  // clearDisplay is the per-device "wipe what's there" (blank-in-place
1171
- // on G2 — no page rebuild).
1186
+ // on G2 - no page rebuild).
1172
1187
  let prevLegacyType = viewStates[stateIndex].layoutType
1173
1188
  if !prevLegacyType.isEmpty, prevLegacyType != "clear_view", prevLegacyType != "scene" {
1174
1189
  sgc?.clearDisplay()
@@ -1187,7 +1202,7 @@ struct ViewState {
1187
1202
  }
1188
1203
 
1189
1204
  // Store the REDISPATCH form: any later sendCurrentState (dashboard
1190
- // exit, head-up return) must repaint the whole frame — the original
1205
+ // exit, head-up return) must repaint the whole frame - the original
1191
1206
  // annotations are only valid for the first dispatch right now.
1192
1207
  sceneStates[stateIndex] = frame.asReplay()
1193
1208
  viewStates[stateIndex] = ViewState(
@@ -1201,7 +1216,7 @@ struct ViewState {
1201
1216
  }
1202
1217
  }
1203
1218
 
1204
- /// Guarded scene dispatch — mirrors sendCurrentState's send conditions.
1219
+ /// Guarded scene dispatch - mirrors sendCurrentState's send conditions.
1205
1220
  private func dispatchSceneFrame(_ frame: SceneFrame) {
1206
1221
  if screenDisabled { return }
1207
1222
  if sgc?.type.contains(DeviceTypes.SIMULATED) ?? true { return }
@@ -1330,6 +1345,24 @@ struct ViewState {
1330
1345
  (sgc as? MentraLive)?.sendOtaQueryStatus()
1331
1346
  }
1332
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
+
1333
1366
  private func liveSgc() throws -> MentraLive {
1334
1367
  guard let live = sgc as? MentraLive else {
1335
1368
  throw BluetoothSdkError(code: "unsupported_device", message: "This command requires Mentra Live glasses.")
@@ -1409,7 +1442,9 @@ struct ViewState {
1409
1442
  size: PhotoSize,
1410
1443
  mode: PhotoMode = .photo,
1411
1444
  exposureTimeNs: Double?,
1412
- durationMs: Int
1445
+ durationMs: Int,
1446
+ zsl: Bool? = nil,
1447
+ mfnr: Bool? = nil
1413
1448
  ) throws {
1414
1449
  guard let live = sgc as? MentraLive else {
1415
1450
  // Fail fast like other camera commands so the SDK promise rejects immediately instead
@@ -1422,7 +1457,9 @@ struct ViewState {
1422
1457
  size: size,
1423
1458
  mode: mode,
1424
1459
  exposureTimeNs: exposureTimeNs,
1425
- durationMs: durationMs
1460
+ durationMs: durationMs,
1461
+ zsl: zsl,
1462
+ mfnr: mfnr
1426
1463
  )
1427
1464
  }
1428
1465
 
@@ -1498,7 +1535,7 @@ struct ViewState {
1498
1535
  )
1499
1536
  }
1500
1537
 
1501
- /// 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).
1502
1539
  func getGlassesMediaVolume() async throws -> [String: Any] {
1503
1540
  guard let live = sgc as? MentraLive else {
1504
1541
  throw NSError(
@@ -1561,7 +1598,7 @@ struct ViewState {
1561
1598
  )
1562
1599
  guard let sgc else {
1563
1600
  Bridge.log(
1564
- "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)"
1565
1602
  )
1566
1603
  return
1567
1604
  }
@@ -1573,13 +1610,19 @@ struct ViewState {
1573
1610
  Bridge.log("MAN: No default wearable, returning")
1574
1611
  return
1575
1612
  }
1576
- if deviceName.isEmpty {
1577
- 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")
1578
1621
  return
1579
1622
  }
1580
1623
  initSGC(defaultWearable)
1581
1624
  searching = true
1582
- sgc?.connectById(deviceName)
1625
+ sgc?.connectById(reconnectTarget)
1583
1626
  connectDefaultController()
1584
1627
  }
1585
1628
 
@@ -1669,6 +1712,11 @@ struct ViewState {
1669
1712
  shouldSendBootingMessage = true // Reset for next first connect
1670
1713
  // clear glasses properties:
1671
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", "")
1672
1720
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
1673
1721
  DeviceStore.shared.apply("glasses", "connected", false)
1674
1722
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
@@ -1700,6 +1748,7 @@ struct ViewState {
1700
1748
  Bridge.saveSetting("default_wearable", "")
1701
1749
  Bridge.saveSetting("device_name", "")
1702
1750
  Bridge.saveSetting("device_address", "")
1751
+ Bridge.saveSetting("project_name", "")
1703
1752
  }
1704
1753
 
1705
1754
  func forgetController() {
@@ -1758,3 +1807,4 @@ struct ViewState {
1758
1807
  cancellables.removeAll()
1759
1808
  }
1760
1809
  }
1810
+
@@ -88,12 +88,13 @@ 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)
94
95
  store.set("bluetooth", "button_photo_size", "max")
95
96
  store.set("bluetooth", "button_max_recording_time", 10)
96
- store.set("bluetooth", "camera_fov", ["fov": 102, "roi_position": 0])
97
+ store.set("bluetooth", "camera_fov", ["fov": 118, "roi_position": 0])
97
98
  store.set("bluetooth", "button_video_width", 1280)
98
99
  store.set("bluetooth", "button_video_height", 720)
99
100
  store.set("bluetooth", "button_video_fps", 30)
@@ -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 {
@@ -190,7 +190,7 @@ public final class MentraBluetoothSDK {
190
190
  private var shouldRestoreControllerOnBluetoothRestore = false
191
191
  private var bridgeEventSinkId: String?
192
192
  private var storeListenerId: String?
193
- private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address"]
193
+ private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address", "project_name"]
194
194
  private let videoUploadStopTimeoutMs = 10 * 60 * 1000
195
195
  private var suppressDefaultDeviceEvents = false
196
196
  private var defaultDeviceApplyGeneration = 0
@@ -308,6 +308,7 @@ public final class MentraBluetoothSDK {
308
308
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", device.model.deviceType)
309
309
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", device.name)
310
310
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", device.identifier ?? "")
311
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", device.projectName ?? "")
311
312
  finishDefaultDeviceApply(generation: generation)
312
313
  }
313
314
 
@@ -318,6 +319,7 @@ public final class MentraBluetoothSDK {
318
319
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", "")
319
320
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", "")
320
321
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", "")
322
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", "")
321
323
  finishDefaultDeviceApply(generation: generation)
322
324
  }
323
325
 
@@ -534,7 +536,11 @@ public final class MentraBluetoothSDK {
534
536
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
535
537
  }
536
538
 
537
- @available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for AE ÷3, or explicit per-shot fields). This method still works but will be removed in a future release.")
539
+ public func setLoudnessGateEnabled(_ enabled: Bool) async throws {
540
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "loudness_gate_enabled", enabled)
541
+ }
542
+
543
+ @available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for text sensor size/crop, or explicit per-shot fields). This method still works but will be removed in a future release.")
538
544
  public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
539
545
  try await performSettingsCommand(
540
546
  setting: "button_photo",
@@ -543,7 +549,7 @@ public final class MentraBluetoothSDK {
543
549
  // Mirror Android: clear all cached scan-tuning keys so reconnect sync
544
550
  // does not replay stale values after a reset.
545
551
  let cat = ObservableStore.bluetoothCategory
546
- for key in ["button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
552
+ for key in ["button_photo_zsl_mfnr", "button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
547
553
  "button_photo_edge_enhancement", "button_photo_isp_digital_gain",
548
554
  "button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
549
555
  "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
@@ -912,7 +918,9 @@ public final class MentraBluetoothSDK {
912
918
  size: PhotoSize,
913
919
  mode: PhotoMode = .photo,
914
920
  exposureTimeNs: Double?,
915
- durationMs: Int
921
+ durationMs: Int,
922
+ zsl: Bool? = nil,
923
+ mfnr: Bool? = nil
916
924
  ) async throws -> CameraStatusEvent {
917
925
  let effectiveRequestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("warm")
918
926
  let pending = PendingResponse<CameraStatusEvent>(operation: "camera warm up \(effectiveRequestId)")
@@ -924,7 +932,9 @@ public final class MentraBluetoothSDK {
924
932
  size: size,
925
933
  mode: mode,
926
934
  exposureTimeNs: exposureTimeNs,
927
- durationMs: durationMs
935
+ durationMs: durationMs,
936
+ zsl: zsl,
937
+ mfnr: mfnr
928
938
  )
929
939
  let event = try await pending.wait()
930
940
  pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
@@ -1261,6 +1271,20 @@ public final class MentraBluetoothSDK {
1261
1271
 
1262
1272
  func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
1263
1273
 
1274
+ func startAr99OtaFromFile(_ path: String) throws -> Bool {
1275
+ try requireGlassesConnected(operation: "start AR99 OTA")
1276
+ return try DeviceManager.shared.startAr99OtaFromFile(path)
1277
+ }
1278
+
1279
+ func cancelAr99Ota() {
1280
+ DeviceManager.shared.cancelAr99Ota()
1281
+ }
1282
+
1283
+ func sendAr99FactoryReset() throws {
1284
+ try requireGlassesConnected(operation: "factory reset AR99")
1285
+ try DeviceManager.shared.sendAr99FactoryReset()
1286
+ }
1287
+
1264
1288
  private func getFreshGlassesStatus() async -> GlassesStatus {
1265
1289
  let status = glassesStatus
1266
1290
  if !status.connected || !status.buildNumber.isEmpty {
@@ -1967,14 +1991,15 @@ public final class MentraBluetoothSDK {
1967
1991
  guard let model = core["default_wearable"] as? String, !model.isEmpty else { return nil }
1968
1992
  guard let name = core["device_name"] as? String, !name.isEmpty else { return nil }
1969
1993
  let identifier = (core["device_address"] as? String).flatMap { $0.isEmpty ? nil : $0 }
1994
+ let projectName = (core["project_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
1970
1995
  return Device(
1971
1996
  model: DeviceModel.fromDeviceType(model),
1972
1997
  name: name,
1973
- identifier: identifier
1998
+ identifier: identifier,
1999
+ projectName: projectName
1974
2000
  )
1975
2001
  }
1976
-
1977
- private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2002
+ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
1978
2003
  guard let results = rawSearchResults as? [[String: Any]] else { return }
1979
2004
  for result in results {
1980
2005
  guard let name = result["name"] as? String else { continue }
@@ -2146,3 +2171,7 @@ public final class MentraBluetoothSDK {
2146
2171
  }
2147
2172
  }
2148
2173
  }
2174
+
2175
+
2176
+
2177
+
@@ -88,9 +88,18 @@ enum OtaManifestChecker {
88
88
  currentBuildNumber: String,
89
89
  currentMtkVersion: String,
90
90
  currentBesVersion: String,
91
- manifest: OtaManifest
91
+ manifest: OtaManifest,
92
+ // Downgrade floor: a non-positive value (the default) disables downgrades entirely,
93
+ // matching ASG's fail-closed DowngradeGate and the engine. OEM SDK consumers that do not
94
+ // set a floor get upgrade-only behavior and are never told a downgrade is available that
95
+ // the glasses would refuse.
96
+ downgradeFloorVersionCode: Int = 0
92
97
  ) throws -> Bool {
93
- try hasApkUpdate(currentBuildNumber: currentBuildNumber, manifest: manifest) ||
98
+ try hasApkUpdate(
99
+ currentBuildNumber: currentBuildNumber,
100
+ manifest: manifest,
101
+ downgradeFloorVersionCode: downgradeFloorVersionCode
102
+ ) ||
94
103
  hasMtkUpdate(patches: manifest.mtkPatches, currentVersion: currentMtkVersion) ||
95
104
  hasBesUpdate(besFirmware: manifest.besFirmware, currentVersion: currentBesVersion)
96
105
  }
@@ -113,7 +122,11 @@ enum OtaManifestChecker {
113
122
  throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
114
123
  }
115
124
 
116
- private static func hasApkUpdate(currentBuildNumber: String, manifest: OtaManifest) throws -> Bool {
125
+ private static func hasApkUpdate(
126
+ currentBuildNumber: String,
127
+ manifest: OtaManifest,
128
+ downgradeFloorVersionCode: Int
129
+ ) throws -> Bool {
117
130
  guard let currentVersion = Int(currentBuildNumber) else {
118
131
  throw BluetoothSdkError(
119
132
  code: "invalid_glasses_version",
@@ -123,7 +136,37 @@ enum OtaManifestChecker {
123
136
  guard let serverVersion = try latestAppInfo(manifest).versionCode else {
124
137
  throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
125
138
  }
126
- return serverVersion > currentVersion
139
+ // Only apps-shaped manifests are exact pins. A legacy top-level manifest (versionCode at
140
+ // the root, no apps entry) is NOT a pin and stays strictly upgrade-only, matching the
141
+ // engine TS checker and Kotlin.
142
+ let isExactPin = manifest.apps?[asgClientPackage]?.versionCode != nil
143
+ if !isExactPin {
144
+ return serverVersion > currentVersion
145
+ }
146
+ // Exact pin: any mismatch is an update in either direction (downgrades take the
147
+ // uninstall-then-reinstall detour on the glasses). A non-positive pin is only legitimate
148
+ // in the frozen legacy rescue manifests that pre-39 glasses are checked against; for
149
+ // modern glasses it means the manifest cannot verify anything, and that must surface as
150
+ // an error — never as "no update". Mirrors the Kotlin OtaManifestChecker.
151
+ if serverVersion <= 0 {
152
+ if currentVersion >= 39 {
153
+ throw BluetoothSdkError(
154
+ code: "invalid_ota_manifest",
155
+ message: "OTA manifest ASG pin is missing or zero; cannot verify update state."
156
+ )
157
+ }
158
+ return false
159
+ }
160
+ if serverVersion > currentVersion {
161
+ return true
162
+ }
163
+ // Exact match: already on the pin, no update.
164
+ if serverVersion == currentVersion {
165
+ return false
166
+ }
167
+ // Downgrade: only actionable at/above the enabled floor; a non-positive floor disables
168
+ // downgrades (fail closed, matching ASG and the engine).
169
+ return downgradeFloorVersionCode > 0 && serverVersion >= downgradeFloorVersionCode
127
170
  }
128
171
 
129
172
  private static func hasMtkUpdate(patches: [MtkPatch]?, currentVersion: String) throws -> Bool {
@@ -169,15 +169,16 @@ public enum CameraRoiPosition: Int {
169
169
  public struct CameraFov {
170
170
  public static let minFov = 62
171
171
  public static let maxFov = 118
172
- public static let defaultFov = 102
172
+ public static let defaultFov = 118
173
173
  public static let narrowFov = 82
174
+ public static let standardFov = 102
174
175
  public static let defaultRoiPosition = CameraRoiPosition.center
175
176
  public static let narrow = CameraFov(
176
177
  fov: CameraFov.narrowFov,
177
178
  roiPosition: CameraFov.defaultRoiPosition
178
179
  )
179
180
  public static let standard = CameraFov(
180
- fov: CameraFov.defaultFov,
181
+ fov: CameraFov.standardFov,
181
182
  roiPosition: CameraFov.defaultRoiPosition
182
183
  )
183
184
  public static let wide = CameraFov(
@@ -302,12 +303,26 @@ public struct PhotoRequest {
302
303
  self.ispDigitalGain = ispDigitalGain
303
304
  self.ispAnalogGain = ispAnalogGain
304
305
  self.mode = mode
305
- self.transferMethod = transferMethod == "ble" ? "ble" : "auto"
306
+ self.transferMethod = transferMethod
306
307
  }
307
308
 
308
- public static func from(params: [String: Any]) -> PhotoRequest {
309
+ public static func from(params: [String: Any]) throws -> PhotoRequest {
309
310
  let sizeRaw = params["size"] as? String ?? "medium"
310
311
  let compressRaw = params["compress"] as? String ?? "none"
312
+ let transferMethod: String
313
+ if let rawValue = params["transferMethod"] {
314
+ guard let rawString = rawValue as? String,
315
+ rawString == "auto" || rawString == "direct" || rawString == "ble"
316
+ else {
317
+ throw BluetoothSdkError(
318
+ code: "invalid_photo_transfer_method",
319
+ message: "Invalid transferMethod \(String(describing: rawValue)). Expected auto, direct, or ble."
320
+ )
321
+ }
322
+ transferMethod = rawString
323
+ } else {
324
+ transferMethod = "auto"
325
+ }
311
326
  let exposureTimeNs: Double?
312
327
  switch params["exposureTimeNs"] {
313
328
  case let value as Double:
@@ -371,7 +386,7 @@ public struct PhotoRequest {
371
386
  ispDigitalGain: optionalInt("ispDigitalGain"),
372
387
  ispAnalogGain: params["ispAnalogGain"] as? String,
373
388
  mode: PhotoMode(normalizedRawValue: params["mode"] as? String),
374
- transferMethod: params["transferMethod"] as? String ?? "auto"
389
+ transferMethod: transferMethod
375
390
  )
376
391
  }
377
392
 
@@ -6,7 +6,7 @@ enum BleTraceLogger {
6
6
  private static let log = OSLog(subsystem: "com.mentra.bluetoothsdk", category: "MentraBleTrace")
7
7
  private static let maxPayloadChars = 3000
8
8
  private static let k900Type = "k900"
9
- private static let sensitiveKeyParts = ["password", "pass", "token", "secret", "authorization", "auth", "email"]
9
+ private static let sensitiveKeyParts = ["password", "pass", "token", "secret", "authorization", "auth", "email", "serial"]
10
10
 
11
11
  static func logJson(
12
12
  direction: String,
@@ -39,6 +39,7 @@ final class BluetoothSdkAnalytics {
39
39
  private let configuration: BluetoothSdkAnalyticsConfiguration
40
40
  private var startedCaptured = false
41
41
  private var lastConnected = false
42
+ private var identifiedCapturedForConnection = false
42
43
 
43
44
  init(configuration: BluetoothSdkAnalyticsConfiguration) {
44
45
  self.configuration = configuration.resolvedForApp()
@@ -47,6 +48,12 @@ final class BluetoothSdkAnalytics {
47
48
  func initializeGlassesStatus(_ status: GlassesStatus) {
48
49
  stateQueue.sync {
49
50
  lastConnected = status.analyticsConnected
51
+ // Only treat identification as already captured when a valid serial is
52
+ // present at init. If the glasses are connected but the serial has not
53
+ // arrived yet (Mentra Live fills it via version_info after connect), leave
54
+ // this false so the identify event still fires once the serial arrives.
55
+ identifiedCapturedForConnection =
56
+ status.analyticsConnected && status.serialNumber.validManufacturingSerial != nil
50
57
  }
51
58
  }
52
59
 
@@ -72,7 +79,12 @@ final class BluetoothSdkAnalytics {
72
79
  let wasConnected = lastConnected
73
80
  lastConnected = isConnected
74
81
  guard configuration.isReady else { return }
82
+ guard isConnected else {
83
+ identifiedCapturedForConnection = false
84
+ return
85
+ }
75
86
  if isConnected, !wasConnected {
87
+ identifiedCapturedForConnection = false
76
88
  var properties: [String: Any] = [
77
89
  "event_kind": "glasses_connected",
78
90
  "fully_booted": status.fullyBooted,
@@ -81,7 +93,25 @@ final class BluetoothSdkAnalytics {
81
93
  properties["glasses_model"] = status.deviceModel
82
94
  }
83
95
  capture(event: "bluetooth_sdk_glasses_connected", properties: properties, configuration: configuration)
96
+ // Fall through: a serial already present at connect time (G1/Ar99
97
+ // report it in the advertisement) should be identified now rather than
98
+ // waiting for some later, unrelated glasses-store update to run.
99
+ }
100
+
101
+ guard !identifiedCapturedForConnection,
102
+ let serialNumber = status.serialNumber.validManufacturingSerial
103
+ else { return }
104
+ identifiedCapturedForConnection = true
105
+ var properties: [String: Any] = [
106
+ "event_kind": "glasses_identified",
107
+ "fully_booted": status.fullyBooted,
108
+ "glasses_device_id": serialNumber,
109
+ "glasses_device_id_type": "manufacturing_serial",
110
+ ]
111
+ if !status.deviceModel.isEmpty {
112
+ properties["glasses_model"] = status.deviceModel
84
113
  }
114
+ capture(event: "bluetooth_sdk_glasses_identified", properties: properties, configuration: configuration)
85
115
  }
86
116
  }
87
117
 
@@ -116,6 +146,7 @@ final class BluetoothSdkAnalytics {
116
146
  "event_source": "mentra_bluetooth_sdk",
117
147
  "sdk_platform": "ios",
118
148
  "sdk_surface": configuration.surface,
149
+ "app_identifier": Bundle.main.bundleIdentifier ?? "",
119
150
  "app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
120
151
  "os_platform": "ios",
121
152
  "os_version": ProcessInfo.processInfo.operatingSystemVersionString,
@@ -148,6 +179,14 @@ private extension GlassesStatus {
148
179
  }
149
180
  }
150
181
 
182
+ private extension String {
183
+ var validManufacturingSerial: String? {
184
+ let normalized = trimmingCharacters(in: .whitespacesAndNewlines)
185
+ guard !normalized.isEmpty, normalized.contains(where: { $0 != "0" }) else { return nil }
186
+ return normalized
187
+ }
188
+ }
189
+
151
190
  private extension BluetoothSdkAnalyticsConfiguration {
152
191
  func resolvedForApp() -> BluetoothSdkAnalyticsConfiguration {
153
192
  let disabledByApp = Bundle.main.object(forInfoDictionaryKey: "MentraBluetoothSdkAnalyticsDisabled") as? Bool == true