@mentra/bluetooth-sdk 3.2.0-dev.265 → 3.2.1-beta.312

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 (54) hide show
  1. package/README.md +28 -0
  2. package/android/lc3Lib/build.gradle +5 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +35 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +30 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +20 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  8. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +12 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +7 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +4 -5
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +345 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +13 -0
  14. package/build/BluetoothSdk.types.d.ts +93 -1
  15. package/build/BluetoothSdk.types.d.ts.map +1 -1
  16. package/build/BluetoothSdk.types.js.map +1 -1
  17. package/build/_private/BluetoothSdkModule.d.ts +32 -1
  18. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  19. package/build/_private/BluetoothSdkModule.js +15 -1
  20. package/build/_private/BluetoothSdkModule.js.map +1 -1
  21. package/build/generated/changelogCatalog.d.ts +5 -2
  22. package/build/generated/changelogCatalog.d.ts.map +1 -1
  23. package/build/generated/changelogCatalog.js +6 -2
  24. package/build/generated/changelogCatalog.js.map +1 -1
  25. package/build/generated/releaseMetadata.js +6 -6
  26. package/build/generated/releaseMetadata.js.map +1 -1
  27. package/build/index.d.ts.map +1 -1
  28. package/build/index.js +1 -0
  29. package/build/index.js.map +1 -1
  30. package/ios/BluetoothSdkModule.swift +60 -0
  31. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  32. package/ios/Source/Bridge.swift +27 -0
  33. package/ios/Source/DeviceManager.swift +52 -50
  34. package/ios/Source/DeviceStore.swift +6 -0
  35. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  36. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  37. package/ios/Source/MentraBluetoothSDK.swift +11 -0
  38. package/ios/Source/audio/PcmStreamPlayer.swift +12 -0
  39. package/ios/Source/events/BluetoothEvents.swift +15 -0
  40. package/ios/Source/services/PhoneMic.swift +1 -3
  41. package/ios/Source/sgcs/MentraLive.swift +247 -1
  42. package/ios/Source/sgcs/SGCManager.swift +32 -0
  43. package/ios/Source/utils/AudioRouteObservation.swift +54 -0
  44. package/ios/Source/utils/AudioSessionMonitor.swift +20 -6
  45. package/ios/Tests/AudioRouteObservationTests.swift +142 -0
  46. package/ios/Tests/BluetoothAudioRouteTests.swift +37 -0
  47. package/ios/Tests/GalleryServerCommandTests.swift +87 -0
  48. package/ios/Tests/WearTuningDispatchTests.swift +51 -0
  49. package/package.json +2 -2
  50. package/src/BluetoothSdk.types.ts +99 -0
  51. package/src/_private/BluetoothSdkModule.ts +64 -1
  52. package/src/generated/changelogCatalog.ts +6 -2
  53. package/src/generated/releaseMetadata.ts +6 -6
  54. package/src/index.ts +1 -0
@@ -50,6 +50,8 @@ struct ViewState {
50
50
  private var cancellables = Set<AnyCancellable>()
51
51
  #if os(macOS)
52
52
  private var audioRouteObserver: MacAudioRouteObserver?
53
+ #else
54
+ private var audioRouteObservation: AudioRouteObservation?
53
55
  #endif
54
56
  var sendStateWorkItem: DispatchWorkItem?
55
57
  let sendStateQueue = DispatchQueue(label: "sendStateQueue", qos: .userInitiated)
@@ -366,6 +368,18 @@ struct ViewState {
366
368
  Bridge.log("MAN: init()")
367
369
  super.init()
368
370
 
371
+ #if !os(macOS)
372
+ // Pair Audio precedes BLE readiness and microphone capture. Its audio
373
+ // state must not depend on the lazily created PhoneMic singleton.
374
+ audioRouteObservation = AudioRouteObservation(names: [
375
+ AVAudioSession.routeChangeNotification,
376
+ UIApplication.didBecomeActiveNotification,
377
+ ]) { [weak self] in
378
+ self?.refreshAudioDeviceState()
379
+ self?.updateMicState()
380
+ }
381
+ #endif
382
+
369
383
  // Start memory monitoring (logs every 30s to help detect leaks)
370
384
  // MemoryMonitor.start()
371
385
 
@@ -929,6 +943,16 @@ struct ViewState {
929
943
  }
930
944
 
931
945
  func checkCurrentAudioDevice() {
946
+ #if os(macOS)
947
+ refreshAudioDeviceState()
948
+ #else
949
+ // DeviceStore calls this when the selected glasses change. Replace any
950
+ // pending route checks so the new target gets its own settling window.
951
+ audioRouteObservation?.refresh()
952
+ #endif
953
+ }
954
+
955
+ private func refreshAudioDeviceState() {
932
956
  let audioDevicePattern = getAudioDevicePattern()
933
957
  Bridge.log("MAN: checkCurrentAudioDevice: audioDevicePattern: \(audioDevicePattern)")
934
958
 
@@ -942,59 +966,13 @@ struct ViewState {
942
966
  glassesBluetoothClassicConnected = AudioSessionMonitor.isAudioDeviceConnected(devicePattern: audioDevicePattern)
943
967
  otherBtConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(devicePattern: audioDevicePattern)
944
968
  #else
945
- // check if the device disconnected:
946
- let isConnected = AudioSessionMonitor.isAudioDeviceConnected(
947
- devicePattern: audioDevicePattern
948
- )
949
-
950
- if !isConnected {
951
- Bridge.log("MAN: Device '\(deviceName)' disconnected")
952
- glassesBluetoothClassicConnected = false
953
-
954
- let isOtherDeviceConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(
955
- devicePattern: audioDevicePattern
956
- )
957
- if isOtherDeviceConnected {
958
- Bridge.log("MAN: Other device connected, returning")
959
- otherBtConnected = true
960
- }
961
- return
962
- }
963
-
964
- let isPaired = AudioSessionMonitor.isDevicePaired(devicePattern: audioDevicePattern)
965
- if isPaired {
966
- Bridge.log("MAN: Successfully detected newly paired device '\(audioDevicePattern)'")
967
- glassesBluetoothClassicConnected = true
968
- } else {
969
- glassesBluetoothClassicConnected = false
970
- }
969
+ // A connected HFP input remains eligible even while another output is
970
+ // selected. Inspect available inputs as well as the active A2DP route.
971
+ glassesBluetoothClassicConnected = AudioSessionMonitor.isDevicePaired(devicePattern: audioDevicePattern)
972
+ otherBtConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(devicePattern: audioDevicePattern)
971
973
  #endif
972
974
  }
973
975
 
974
- #if !os(macOS)
975
- func onRouteChange(
976
- reason: AVAudioSession.RouteChangeReason, availableInputs: [AVAudioSessionPortDescription]
977
- ) {
978
- Bridge.log("MAN: onRouteChange: reason: \(reason)")
979
- Bridge.log("MAN: onRouteChange: inputs: \(availableInputs)")
980
-
981
- // check if our deviceName is connected:
982
- // (return if deviceName is empty):
983
- if deviceName.isEmpty {
984
- Bridge.log("MAN: Device name is empty, returning")
985
- return
986
- }
987
-
988
- // Add small delay to let iOS populate availableInputs
989
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
990
- guard let self = self else { return }
991
- checkCurrentAudioDevice()
992
- }
993
-
994
- updateMicState()
995
- }
996
- #endif
997
-
998
976
  func onInterruption(began: Bool) {
999
977
  Bridge.log("MAN: Interruption: \(began)")
1000
978
  systemMicUnavailable = began
@@ -1488,6 +1466,26 @@ struct ViewState {
1488
1466
 
1489
1467
  func dbg2() {}
1490
1468
 
1469
+ func queryWearState() {
1470
+ sgc?.queryWearState()
1471
+ }
1472
+
1473
+ func setWearReporting(_ enabled: Bool) {
1474
+ sgc?.setWearReporting(enabled)
1475
+ }
1476
+
1477
+ func setWearTuning(intervalMs: Int, count: Int, majority: Int) {
1478
+ sgc?.setWearTuning(intervalMs: intervalMs, count: count, majority: majority)
1479
+ }
1480
+
1481
+ func requestWearTuning() {
1482
+ sgc?.requestWearTuning()
1483
+ }
1484
+
1485
+ func resetWearTuning() {
1486
+ sgc?.resetWearTuning()
1487
+ }
1488
+
1491
1489
  func startStream(_ message: [String: Any]) {
1492
1490
  var message = message
1493
1491
  Bridge.log("MAN: startStream: \(message)")
@@ -1586,6 +1584,10 @@ struct ViewState {
1586
1584
  return live
1587
1585
  }
1588
1586
 
1587
+ func sendGalleryServerEnabled(requestId: String, enabled: Bool) throws {
1588
+ try liveSgc().sendGalleryServerEnabled(requestId: requestId, enabled: enabled)
1589
+ }
1590
+
1589
1591
  func sendGalleryMode(requestId: String, enabled: Bool) throws {
1590
1592
  try liveSgc().sendGalleryMode(requestId: requestId, active: enabled)
1591
1593
  }
@@ -254,6 +254,12 @@ class DeviceStore {
254
254
  case ("bluetooth", "loudness_gate_enabled"):
255
255
  DeviceManager.shared.sgc?.sendLoudnessGateSetting()
256
256
 
257
+ // Deliberately has no seeded default: the key starts absent so that a
258
+ // session where the engine has not authorized tuning can only ever
259
+ // send a reset.
260
+ case ("bluetooth", "mic_tuning"):
261
+ DeviceManager.shared.sgc?.sendMicTuningSetting()
262
+
257
263
  case ("bluetooth", "screen_disabled"):
258
264
  if let disabled = value as? Bool {
259
265
  if disabled {
@@ -2,7 +2,8 @@ import Foundation
2
2
 
3
3
  /// Generated from /changelogs. Do not edit directly.
4
4
  let generatedReleaseChangelogs: [ReleaseChangelog] = [
5
+ ReleaseChangelog(version: "3.2.1", markdown: "This release is under active development. User-facing changes will be documented as they land."),
5
6
  ReleaseChangelog(version: "3.2.0", markdown: "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
6
- ReleaseChangelog(version: "3.1.1", markdown: "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them."),
7
- ReleaseChangelog(version: "3.1.0", markdown: "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them."),
7
+ ReleaseChangelog(version: "3.1.1", markdown: "- Improved Google login on iOS.\n- Pairing no longer requires phone microphone access."),
8
+ ReleaseChangelog(version: "3.1.0", markdown: "**Mentra App and glasses**\n\n- Improved glasses pairing and reconnection, including on iPad.\n- Improved Android background reliability for captions and miniapps.\n- Added Xingyi AR99 support.\n- More reliable photo and video capture, with improved shutter sounds and privacy-light timing. Taking photos no longer interrupts your running miniapp.\n- Added video thumbnails and smoother gallery browsing.\n- Easier Mentra Live updates through your phone, with clearer progress and better recovery from interruptions.\n\n**Miniapp updates**\n\n- **Mentra AI:** Control whether photos are included with questions. Improved visual answers and connected-app follow-ups.\n- **Captions:** Added offline transcription settings and top/bottom positioning on supported displays.\n- **Translation:** Restored all 60 languages.\n- **Mentra Maps:** Clearer directions on G1/G2, less display flickering, and improved Android offline routing.\n- **Livestreamer:** More video-quality controls and custom streaming options, plus improved startup and sharing.\n- **Merge:** Tap to expand an insight; swipe down or back to dismiss it.\n- **Mentra Notes:** Smoother back navigation.\n- **Recorder:** Fixed audio sharing."),
8
9
  ]
@@ -2,10 +2,10 @@ import Foundation
2
2
 
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
- static let familyBaseVersion = "3.2.0"
6
- static let releaseIdentity = "3.2.0-dev.265"
7
- static let releaseSetId = "mentra-3.2.0-dev.265"
8
- static let sourceCommit = "e8e1ced74c05705b313769d49dfbf98f8bf5b1d2"
9
- static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.265.json"
10
- static let otaManifestSha256 = "c75ed12b14a9897db8c89a9bd3baa6bad432aa908e7fc0b0f50af8c7d8b8a9ad"
5
+ static let familyBaseVersion = "3.2.1"
6
+ static let releaseIdentity = "3.2.1-beta.312"
7
+ static let releaseSetId = "mentra-3.2.1-beta.312"
8
+ static let sourceCommit = "0e549d30e1de230cc9f22e77b80ca1704833cceb"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.312.json"
10
+ static let otaManifestSha256 = "c7d7975d15ab271035e7e26431be200d91664cc4c85b761e373ed4bc4026a3ac"
11
11
  }
@@ -639,6 +639,17 @@ public final class MentraBluetoothSDK {
639
639
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "screen_disabled", disabled)
640
640
  }
641
641
 
642
+ /// Enable or disable persistent, unauthenticated HTTP gallery access on the glasses' Wi-Fi.
643
+ /// Defaults off and survives glasses restarts. Only enable on a trusted network.
644
+ /// The ack reports the saved `enabled` state and current `listening`/`url` separately.
645
+ public func setGalleryServerEnabled(_ enabled: Bool) async throws -> SettingsAckEvent {
646
+ try await performSettingsCommand(
647
+ setting: "gallery_server",
648
+ updateStore: { _ in },
649
+ send: { requestId in try DeviceManager.shared.sendGalleryServerEnabled(requestId: requestId, enabled: enabled) }
650
+ )
651
+ }
652
+
642
653
  public func setGalleryModeEnabled(_ enabled: Bool) async throws -> SettingsAckEvent {
643
654
  try await performSettingsCommand(
644
655
  setting: "gallery_mode",
@@ -1,5 +1,6 @@
1
1
  import AVFoundation
2
2
  import Foundation
3
+ import os
3
4
 
4
5
  private enum PcmStreamError: LocalizedError {
5
6
  case invalidArgument(String)
@@ -25,6 +26,7 @@ private enum PcmStreamError: LocalizedError {
25
26
  /// recovery, close, and abort deterministic without blocking Expo's module
26
27
  /// queue.
27
28
  final class PcmStreamPlayer: @unchecked Sendable {
29
+ private let logger = Logger(subsystem: "com.mentra.pcm-stream", category: "playback")
28
30
  private static let backpressureCeilingMs: Int64 = 2000
29
31
  private static let maximumBacklogMs: Int64 = 10000
30
32
 
@@ -37,6 +39,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
37
39
 
38
40
  private var queuedFrames: Int64 = 0
39
41
  private var playedFrames: Int64 = 0
42
+ private var writtenFrames: Int64 = 0
40
43
  private var closing = false
41
44
  private var aborted = false
42
45
  private var finished = false
@@ -107,6 +110,10 @@ final class PcmStreamPlayer: @unchecked Sendable {
107
110
  }
108
111
 
109
112
  let buffer = try makeBuffer(data: data, frameCount: frameCount)
113
+ if writtenFrames == 0 {
114
+ logger.info("PCM_PLAYBACK first_write frames=\(frameCount) sampleRate=\(self.sampleRate) engineRunning=\(self.engine.isRunning)")
115
+ }
116
+ writtenFrames += Int64(frameCount)
110
117
  queuedFrames += Int64(frameCount)
111
118
  player.scheduleBuffer(
112
119
  buffer,
@@ -157,6 +164,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
157
164
  await withCheckedContinuation { continuation in
158
165
  stateQueue.async { [self] in
159
166
  if !aborted {
167
+ logger.info("PCM_PLAYBACK abort writtenFrames=\(self.writtenFrames) playedFrames=\(self.playedFrames) queuedFrames=\(self.queuedFrames)")
160
168
  aborted = true
161
169
  finished = true
162
170
  player.stop()
@@ -201,6 +209,10 @@ final class PcmStreamPlayer: @unchecked Sendable {
201
209
 
202
210
  private func didPlay(frameCount: Int) {
203
211
  guard !aborted, terminalError == nil else { return }
212
+ if playedFrames == 0 {
213
+ let rate = sampleRate
214
+ logger.info("PCM_PLAYBACK first_played frames=\(frameCount) sampleRate=\(rate)")
215
+ }
204
216
  queuedFrames = max(0, queuedFrames - Int64(frameCount))
205
217
  playedFrames += Int64(frameCount)
206
218
 
@@ -206,6 +206,21 @@ public struct SettingsAckEvent: CustomStringConvertible {
206
206
  stringValue(values, "errorMessage")
207
207
  }
208
208
 
209
+ /// Saved setting value, when included in the acknowledgement.
210
+ public var enabled: Bool? {
211
+ boolValue(values, "enabled")
212
+ }
213
+
214
+ /// Whether the persistent gallery server is currently available over station Wi-Fi.
215
+ public var listening: Bool? {
216
+ boolValue(values, "listening")
217
+ }
218
+
219
+ /// Current gallery base URL, available only with a running server and station Wi-Fi.
220
+ public var url: String? {
221
+ stringValue(values, "url")
222
+ }
223
+
209
224
  public var description: String {
210
225
  "SettingsAckEvent(setting: \(setting), status: \(status))"
211
226
  }
@@ -359,9 +359,7 @@ class PhoneMic {
359
359
  }
360
360
 
361
361
  Bridge.log("MIC: handleRouteChange: \(reason)")
362
- DeviceManager.shared.onRouteChange(
363
- reason: reason, availableInputs: audioSession?.availableInputs ?? []
364
- )
362
+ // DeviceManager observes readiness independently of microphone capture.
365
363
 
366
364
  // // If we're recording and the audio route changed (e.g., AirPods connected/disconnected)
367
365
  // if isRecording {
@@ -1060,6 +1060,10 @@ extension MentraLive: CBCentralManagerDelegate {
1060
1060
  self.readinessCompletedThisBleSession = false
1061
1061
  self.updateConnectionState(ConnTypes.DISCONNECTED)
1062
1062
  self.rgbLedAuthorityClaimed = false
1063
+ // The glasses reset mic and wear tuning on BLE disconnect, so the
1064
+ // next link must be allowed to send them again.
1065
+ self.lastSentMicTuningBody = nil
1066
+ self.wearTuningQueriedThisLink = false
1063
1067
 
1064
1068
  self.stopAllTimers()
1065
1069
  self.closeL2capFileChannel()
@@ -1698,6 +1702,21 @@ class MentraLive: NSObject, SGCManager {
1698
1702
  private var ancsRelayEnableRequested = false
1699
1703
  private var peerWireCapsBinary = false
1700
1704
  private var peerFilePayloadV2 = false
1705
+ /// Firmware understands cs_mictun / cs_micst / cs_micrms. Nothing mic-tuning
1706
+ /// related goes on the wire until this is seen.
1707
+ private var peerMicTuning = false
1708
+ /// Firmware understands cs_weartun / cs_wearst. Wear reporting stays off
1709
+ /// until Super Mode asks for it; this flag only gates those commands.
1710
+ private var peerWearTuning = false
1711
+ /// Tuning generation echoed by the last sr_mictun / sr_micst. An sr_micrms
1712
+ /// measured before that revision describes a config we already replaced.
1713
+ private var micTuningGeneration = 0
1714
+ /// Connect-time dedupe, mirroring Android. Several paths push mic tuning
1715
+ /// when a link comes up and the wire_caps parse re-runs per glasses_ready;
1716
+ /// the glasses only forget tuning on BLE disconnect, so an identical body
1717
+ /// within one link is dropped. Both reset in didDisconnectPeripheral.
1718
+ private var lastSentMicTuningBody: String?
1719
+ private var wearTuningQueriedThisLink = false
1701
1720
  /// Last observed glasses process session id (`sid` in glasses_ready / version_info_1).
1702
1721
  /// The BES keeps the BLE link alive across asg_client restarts, so transport state
1703
1722
  /// cannot signal a restart - a CHANGED (or newly appearing) sid is the restart signal.
@@ -2646,7 +2665,8 @@ class MentraLive: NSObject, SGCManager {
2646
2665
 
2647
2666
  // MARK: - Data Processing
2648
2667
 
2649
- private func processReceivedData(_ data: Data) {
2668
+ // Internal so native integration tests can replay the actual BLE receive path.
2669
+ func processReceivedData(_ data: Data) {
2650
2670
  guard data.count > 0 else { return }
2651
2671
 
2652
2672
  let bytes = [UInt8](data)
@@ -3427,6 +3447,35 @@ class MentraLive: NSObject, SGCManager {
3427
3447
  )
3428
3448
  }
3429
3449
 
3450
+ case "sr_mictun", "sr_micst":
3451
+ if let body = k900ParseBody(json["B"]) {
3452
+ handleMicTuningState(body)
3453
+ }
3454
+
3455
+ case "sr_weartun":
3456
+ if let body = k900ParseBody(json["B"]) {
3457
+ handleWearTuningState(json, body)
3458
+ }
3459
+
3460
+ case "sr_wrst":
3461
+ if let body = k900ParseBody(json["B"]) {
3462
+ Bridge.sendWearState(worn: (k900JsonInt(body, "on") ?? 0) != 0)
3463
+ }
3464
+
3465
+ case "sr_micrms":
3466
+ if let body = k900ParseBody(json["B"]) {
3467
+ let generation = k900JsonInt(body, "gen") ?? 0
3468
+ // Measured under a config we have already replaced.
3469
+ if generation >= micTuningGeneration {
3470
+ Bridge.sendMicRms(
3471
+ rms: k900JsonInt(body, "rms") ?? 0,
3472
+ gateOpen: (k900JsonInt(body, "gate") ?? 0) != 0,
3473
+ speakerElevated: (k900JsonInt(body, "sp") ?? 0) != 0,
3474
+ generation: generation
3475
+ )
3476
+ }
3477
+ }
3478
+
3430
3479
  case "sr_shut":
3431
3480
  Bridge.log("K900 shutdown command received - glasses shutting down")
3432
3481
  // Mark as killed to prevent reconnection attempts
@@ -3781,6 +3830,14 @@ class MentraLive: NSObject, SGCManager {
3781
3830
  sendJson(json, wakeUp: true)
3782
3831
  }
3783
3832
 
3833
+ func sendGalleryServerEnabled(requestId: String, enabled: Bool) {
3834
+ sendJson([
3835
+ "type": "set_gallery_server_enabled",
3836
+ "request_id": requestId,
3837
+ "enabled": enabled,
3838
+ ], wakeUp: true)
3839
+ }
3840
+
3784
3841
  func sendGalleryMode() {
3785
3842
  let active = DeviceStore.shared.get("bluetooth", "gallery_mode") as! Bool
3786
3843
  sendGalleryMode(requestId: nil, active: active)
@@ -6049,6 +6106,9 @@ extension MentraLive {
6049
6106
  peerK900Le = false
6050
6107
  peerWireCapsBinary = false
6051
6108
  peerFilePayloadV2 = false
6109
+ peerWearTuning = false
6110
+ peerMicTuning = false
6111
+ micTuningGeneration = 0
6052
6112
  BleJsonCompact.resetSession()
6053
6113
  wireHandshakeSentGeneration = -1
6054
6114
  }
@@ -6118,6 +6178,33 @@ extension MentraLive {
6118
6178
  if caps.keys.contains("file_payload_v2") {
6119
6179
  peerFilePayloadV2 = (caps["file_payload_v2"] as? Bool) == true
6120
6180
  }
6181
+ if caps.keys.contains("wear_tuning"), !peerWearTuning {
6182
+ let supported = (caps["wear_tuning"] as? Bool) == true
6183
+ || ((caps["wear_tuning"] as? NSNumber)?.intValue ?? 0) != 0
6184
+ if supported {
6185
+ peerWearTuning = true
6186
+ Bridge.log("LIVE: wire_caps wear_tuning supported")
6187
+ // Nothing to push: wear reporting starts off on the glasses
6188
+ // and stays off until the tuning screen asks for it. One read
6189
+ // per link: the flag is cleared on every wire epoch, but the
6190
+ // glasses only forget tuning on BLE disconnect.
6191
+ if !wearTuningQueriedThisLink {
6192
+ wearTuningQueriedThisLink = true
6193
+ requestWearTuning()
6194
+ }
6195
+ }
6196
+ }
6197
+ if caps.keys.contains("mic_tuning"), !peerMicTuning {
6198
+ let supported = (caps["mic_tuning"] as? Bool) == true
6199
+ || ((caps["mic_tuning"] as? NSNumber)?.intValue ?? 0) != 0
6200
+ if supported {
6201
+ peerMicTuning = true
6202
+ // Caps can land after the on-connect batch already ran, which
6203
+ // would have skipped the tuning send.
6204
+ Bridge.log("LIVE: wire_caps mic_tuning supported")
6205
+ sendMicTuningSetting()
6206
+ }
6207
+ }
6121
6208
  }
6122
6209
 
6123
6210
  private func advertiseFilePayloadCapabilityToBes() {
@@ -6786,6 +6873,165 @@ extension MentraLive {
6786
6873
 
6787
6874
  // Send glasses-side loudness / Barrier gate setting.
6788
6875
  sendLoudnessGateSetting()
6876
+
6877
+ // Send mic tuning. With nothing authorized this sends a reset, which is
6878
+ // what returns a freshly connected pair of glasses to stock behaviour.
6879
+ sendMicTuningSetting()
6880
+ }
6881
+
6882
+ /// Mic tuning field names, matching the BES cs_mictun body.
6883
+ private static let micTuningFields = [
6884
+ "gain", "open", "close", "attack", "hang", "sp_open", "sp_close", "sp_hold",
6885
+ ]
6886
+
6887
+ /// Push the effective mic tuning to the glasses.
6888
+ ///
6889
+ /// The store holds only what the engine has authorized for this process; a
6890
+ /// missing value means "no tuning", which is sent as an explicit reset
6891
+ /// rather than silently skipped. That is what keeps a persisted super-mode
6892
+ /// value from surviving into a session where super mode is off.
6893
+ func sendMicTuningSetting() {
6894
+ guard connectedPeripheral != nil, txCharacteristic != nil else {
6895
+ Bridge.log("LIVE: Cannot send mic tuning - BLE write path not ready")
6896
+ return
6897
+ }
6898
+ if !peerMicTuning {
6899
+ Bridge.log("LIVE: mic_tuning cap not advertised; sending cs_mictun anyway")
6900
+ }
6901
+
6902
+ var body: [String: Any] = [:]
6903
+ if let fields = DeviceStore.shared.get("bluetooth", "mic_tuning") as? [String: Any] {
6904
+ for name in Self.micTuningFields {
6905
+ if let number = fields[name] as? NSNumber {
6906
+ body[name] = number.intValue
6907
+ }
6908
+ }
6909
+ }
6910
+ if body.isEmpty {
6911
+ body["reset"] = 1
6912
+ }
6913
+
6914
+ // Key order is fixed so equal bodies serialize identically.
6915
+ let serialized = body.keys.sorted().map { "\($0)=\(body[$0]!)" }.joined(separator: ",")
6916
+ if serialized == lastSentMicTuningBody {
6917
+ Bridge.log("LIVE: 🎚️ Mic tuning unchanged this link, not resending: \(serialized)")
6918
+ return
6919
+ }
6920
+
6921
+ Bridge.log("LIVE: 🎚️ Sending mic tuning to glasses: \(body)")
6922
+ sendMicTuningCommand("cs_mictun", body: body)
6923
+ lastSentMicTuningBody = serialized
6924
+ }
6925
+
6926
+ /// Ask the glasses what tuning they are actually running (sr_micst).
6927
+ func requestMicTuningState() {
6928
+ guard connectedPeripheral != nil, txCharacteristic != nil else {
6929
+ Bridge.log("LIVE: Cannot send cs_micst - BLE write path not ready")
6930
+ return
6931
+ }
6932
+ if !peerMicTuning {
6933
+ Bridge.log("LIVE: mic_tuning cap not advertised; sending cs_micst anyway")
6934
+ }
6935
+ sendMicTuningCommand("cs_micst", body: [:])
6936
+ }
6937
+
6938
+ /// Enable or disable the sr_micrms readout.
6939
+ func setMicRmsTelemetry(_ enabled: Bool) {
6940
+ guard connectedPeripheral != nil, txCharacteristic != nil, peerMicTuning else { return }
6941
+ sendMicTuningCommand("cs_micrms", body: ["on": enabled ? 1 : 0])
6942
+ }
6943
+
6944
+ /// Read the current wear state (sr_wrst). Always available.
6945
+ @objc func queryWearState() {
6946
+ sendWearCommandIfReady("cs_wrst", body: [:])
6947
+ }
6948
+
6949
+ /// Turn wear reporting on or off for this session.
6950
+ ///
6951
+ /// Deliberately not the NV-backed cs_swit type 1: the glasses must forget
6952
+ /// this on disconnect, and any later switch write would re-persist a wear
6953
+ /// bit that had been enabled once.
6954
+ @objc func setWearReporting(_ enabled: Bool) {
6955
+ sendWearCommandIfReady("cs_weartun", body: ["enabled": enabled ? 1 : 0])
6956
+ }
6957
+
6958
+ /// Move the debounce vote. Negative means "leave this one alone".
6959
+ @objc func setWearTuning(intervalMs: Int, count: Int, majority: Int) {
6960
+ var body: [String: Any] = [:]
6961
+ if intervalMs >= 0 { body["interval"] = intervalMs }
6962
+ if count >= 0 { body["count"] = count }
6963
+ if majority >= 0 { body["majority"] = majority }
6964
+ guard !body.isEmpty else { return }
6965
+ sendWearCommandIfReady("cs_weartun", body: body)
6966
+ }
6967
+
6968
+ /// Ask what the poll loop is actually running (sr_weartun).
6969
+ @objc func requestWearTuning() {
6970
+ sendWearCommandIfReady("cs_wearst", body: [:])
6971
+ }
6972
+
6973
+ /// Restore firmware defaults and disable reporting. Distinct from sending
6974
+ /// the default vote values, which would leave reporting on.
6975
+ @objc func resetWearTuning() {
6976
+ sendWearCommandIfReady("cs_weartun", body: ["reset": 1])
6977
+ }
6978
+
6979
+ private func sendWearCommandIfReady(_ name: String, body: [String: Any]) {
6980
+ guard connectedPeripheral != nil, txCharacteristic != nil else {
6981
+ Bridge.log("LIVE: Cannot send \(name) - BLE write path not ready")
6982
+ return
6983
+ }
6984
+ if !peerWearTuning && name != "cs_wrst" {
6985
+ Bridge.log("LIVE: wear_tuning cap not advertised; sending \(name) anyway")
6986
+ }
6987
+ sendMicTuningCommand(name, body: body)
6988
+ }
6989
+
6990
+ private func sendMicTuningCommand(_ name: String, body: [String: Any]) {
6991
+ do {
6992
+ let bodyData = try JSONSerialization.data(withJSONObject: body)
6993
+ guard let bodyString = String(data: bodyData, encoding: .utf8) else {
6994
+ Bridge.log("LIVE: Failed to encode \(name) payload")
6995
+ return
6996
+ }
6997
+ let command: [String: Any] = ["C": name, "V": 1, "B": bodyString]
6998
+ if !sendRawK900Command(command, wakeUp: true) {
6999
+ Bridge.log("LIVE: Failed to send \(name)")
7000
+ }
7001
+ } catch {
7002
+ Bridge.log("LIVE: Error encoding \(name) payload: \(error)")
7003
+ }
7004
+ }
7005
+
7006
+ /// Post-clamp tuning the glasses report as in force. Forwarded verbatim so
7007
+ /// the screen can show the applied value next to the requested one.
7008
+ private func handleMicTuningState(_ body: [String: Any]) {
7009
+ var state: [String: Any] = [:]
7010
+ for name in Self.micTuningFields {
7011
+ if let number = body[name] as? NSNumber {
7012
+ state[name] = number.intValue
7013
+ }
7014
+ }
7015
+ let generation = (body["gen"] as? NSNumber)?.intValue ?? 0
7016
+ state["generation"] = generation
7017
+ state["overridden"] = ((body["ovr"] as? NSNumber)?.intValue ?? 0) != 0
7018
+ micTuningGeneration = generation
7019
+ Bridge.sendMicTuningState(state)
7020
+ }
7021
+
7022
+ /// What the wear poll loop is actually running. A rejected patch comes back
7023
+ /// with the unchanged values and a non-zero result code, so the screen can
7024
+ /// show that the request did not take.
7025
+ private func handleWearTuningState(_ json: [String: Any], _ body: [String: Any]) {
7026
+ let state: [String: Any] = [
7027
+ "enabled": (k900JsonInt(body, "enabled") ?? 0) != 0,
7028
+ "interval": k900JsonInt(body, "interval") ?? 0,
7029
+ "count": k900JsonInt(body, "count") ?? 0,
7030
+ "majority": k900JsonInt(body, "majority") ?? 0,
7031
+ "generation": k900JsonInt(body, "gen") ?? 0,
7032
+ "accepted": (k900JsonInt(json, "S") ?? 0) == 0,
7033
+ ]
7034
+ Bridge.sendWearTuningState(state)
6789
7035
  }
6790
7036
 
6791
7037
  func sendVoiceActivityDetectionSetting() {
@@ -228,6 +228,24 @@ protocol SGCManager {
228
228
 
229
229
  func sendLoudnessGateSetting()
230
230
 
231
+ // MARK: - Mic tuning (super-mode only)
232
+
233
+ func sendMicTuningSetting()
234
+ func requestMicTuningState()
235
+ func setMicRmsTelemetry(_ enabled: Bool)
236
+
237
+ // MARK: - Wear detection (super-mode only)
238
+
239
+ // These are protocol requirements, not extension-only members, on purpose:
240
+ // DeviceManager holds an `SGCManager?`, and a method that exists only in
241
+ // the extension is statically dispatched through that reference, so the
242
+ // no-op below would win over MentraLive's implementation.
243
+ func queryWearState()
244
+ func setWearReporting(_ enabled: Bool)
245
+ func setWearTuning(intervalMs: Int, count: Int, majority: Int)
246
+ func requestWearTuning()
247
+ func resetWearTuning()
248
+
231
249
  // MARK: - Version Info
232
250
 
233
251
  func requestVersionInfo()
@@ -417,6 +435,20 @@ extension SGCManager {
417
435
 
418
436
  func sendLoudnessGateSetting() {}
419
437
 
438
+ // MARK: - Mic tuning (default no-op — Mentra Live supports this)
439
+
440
+ func sendMicTuningSetting() {}
441
+ func requestMicTuningState() {}
442
+ func setMicRmsTelemetry(_: Bool) {}
443
+
444
+ // MARK: - Wear detection (default no-op — Mentra Live supports this)
445
+
446
+ func queryWearState() {}
447
+ func setWearReporting(_: Bool) {}
448
+ func setWearTuning(intervalMs _: Int, count _: Int, majority _: Int) {}
449
+ func requestWearTuning() {}
450
+ func resetWearTuning() {}
451
+
420
452
  /// Default no-op; Mentra Live and G2 override to handle phone-detected clock skew.
421
453
  func sendSetSystemTime(_: Int64) {
422
454
  Bridge.log("SGC: sendSetSystemTime not supported")