@mentra/bluetooth-sdk 3.2.1-dev.274 → 3.2.1-dev.277

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.
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.2.1"
6
- const val RELEASE_IDENTITY: String = "3.2.1-dev.274"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.1-dev.274"
8
- const val SOURCE_COMMIT: String = "fbc4e9c568fd067ce99a485fe385244240136232"
9
- const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.274.json"
10
- const val OTA_MANIFEST_SHA256: String = "78f6de80c6904879ea5f011c076ee99189de70e7309a515d877fddd25b789004"
6
+ const val RELEASE_IDENTITY: String = "3.2.1-dev.277"
7
+ const val RELEASE_SET_ID: String = "mentra-3.2.1-dev.277"
8
+ const val SOURCE_COMMIT: String = "2668275c49728812fea5356f6df3e78e73f1ed87"
9
+ const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json"
10
+ const val OTA_MANIFEST_SHA256: String = "527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183"
11
11
  }
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.2.1",
4
- "releaseIdentity": "3.2.1-dev.274",
5
- "releaseSetId": "mentra-3.2.1-dev.274",
6
- "sourceCommit": "fbc4e9c568fd067ce99a485fe385244240136232",
7
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.274.json",
8
- "otaManifestSha256": "78f6de80c6904879ea5f011c076ee99189de70e7309a515d877fddd25b789004"
4
+ "releaseIdentity": "3.2.1-dev.277",
5
+ "releaseSetId": "mentra-3.2.1-dev.277",
6
+ "sourceCommit": "2668275c49728812fea5356f6df3e78e73f1ed87",
7
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json",
8
+ "otaManifestSha256": "527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183"
9
9
  });
10
10
  //# sourceMappingURL=releaseMetadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,wHAAwH;IAC1I,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.1\",\n \"releaseIdentity\": \"3.2.1-dev.274\",\n \"releaseSetId\": \"mentra-3.2.1-dev.274\",\n \"sourceCommit\": \"fbc4e9c568fd067ce99a485fe385244240136232\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.274.json\",\n \"otaManifestSha256\": \"78f6de80c6904879ea5f011c076ee99189de70e7309a515d877fddd25b789004\"\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.1\",\n \"releaseIdentity\": \"3.2.1-dev.277\",\n \"releaseSetId\": \"mentra-3.2.1-dev.277\",\n \"sourceCommit\": \"2668275c49728812fea5356f6df3e78e73f1ed87\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json\",\n \"otaManifestSha256\": \"527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183\"\n})\n"]}
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
11
11
  static let voiceActivityDetectionEnabled = false
12
12
  static let loudnessGateEnabled = false
13
13
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
14
- private static let swiftPackageSdkVersion = "3.2.1-dev.274"
14
+ private static let swiftPackageSdkVersion = "3.2.1-dev.277"
15
15
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
16
16
 
17
17
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -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
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.2.1"
6
- static let releaseIdentity = "3.2.1-dev.274"
7
- static let releaseSetId = "mentra-3.2.1-dev.274"
8
- static let sourceCommit = "fbc4e9c568fd067ce99a485fe385244240136232"
9
- static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.274.json"
10
- static let otaManifestSha256 = "78f6de80c6904879ea5f011c076ee99189de70e7309a515d877fddd25b789004"
6
+ static let releaseIdentity = "3.2.1-dev.277"
7
+ static let releaseSetId = "mentra-3.2.1-dev.277"
8
+ static let sourceCommit = "2668275c49728812fea5356f6df3e78e73f1ed87"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json"
10
+ static let otaManifestSha256 = "527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183"
11
11
  }
@@ -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
 
@@ -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 {
@@ -0,0 +1,54 @@
1
+ import Combine
2
+ import Foundation
3
+
4
+ /// Keeps audio readiness observable before microphone capture has been started.
5
+ final class AudioRouteObservation {
6
+ private var subscriptions = Set<AnyCancellable>()
7
+ private var pendingRecheck: DispatchWorkItem?
8
+ private var generation = 0
9
+ private let settlingDelays: [TimeInterval]
10
+ private let onChange: () -> Void
11
+
12
+ init(
13
+ center: NotificationCenter = .default,
14
+ names: [Notification.Name],
15
+ settlingDelays: [TimeInterval] = [0.2, 0.3, 0.5],
16
+ onChange: @escaping () -> Void
17
+ ) {
18
+ self.settlingDelays = settlingDelays
19
+ self.onChange = onChange
20
+ for name in names {
21
+ center.publisher(for: name)
22
+ .receive(on: DispatchQueue.main)
23
+ .sink { [weak self] _ in self?.refresh() }
24
+ .store(in: &subscriptions)
25
+ }
26
+ }
27
+
28
+ deinit {
29
+ pendingRecheck?.cancel()
30
+ }
31
+
32
+ /// Start a new settling window for a route, foreground, or selected-device
33
+ /// change. The callback must read the current target and route each time.
34
+ func refresh() {
35
+ dispatchPrecondition(condition: .onQueue(.main))
36
+ pendingRecheck?.cancel()
37
+ generation += 1
38
+ reconcile(generation: generation, nextDelay: 0)
39
+ }
40
+
41
+ private func reconcile(generation: Int, nextDelay: Int) {
42
+ guard generation == self.generation else { return }
43
+ onChange()
44
+ // availableInputs can lag the notification, including after removal.
45
+ // Reconcile at 200, 500, and 1000 ms even if the first read was ready.
46
+ // A reentrant refresh also supersedes this window.
47
+ guard generation == self.generation, nextDelay < settlingDelays.count else { return }
48
+ let recheck = DispatchWorkItem { [weak self] in
49
+ self?.reconcile(generation: generation, nextDelay: nextDelay + 1)
50
+ }
51
+ pendingRecheck = recheck
52
+ DispatchQueue.main.asyncAfter(deadline: .now() + settlingDelays[nextDelay], execute: recheck)
53
+ }
54
+ }
@@ -6,9 +6,10 @@
6
6
  // Used to detect when Mentra Live glasses are paired/connected for audio
7
7
  //
8
8
 
9
+ import Foundation
10
+
9
11
  #if !os(macOS)
10
12
  import AVFoundation
11
- import Foundation
12
13
  import UIKit
13
14
 
14
15
  class AudioSessionMonitor {
@@ -57,11 +58,14 @@ class AudioSessionMonitor {
57
58
  Bridge.log("AudioMonitor: Checking active route, output count: \(outputs.count)")
58
59
  for output in outputs {
59
60
  Bridge.log("AudioMonitor: - \(output.portName) (type: \(output.portType.rawValue))")
60
- if output.portType == .bluetoothHFP || output.portType == .bluetoothA2DP {
61
- if output.portName.localizedCaseInsensitiveContains(devicePattern) {
62
- Bridge.log("AudioMonitor: ✅ Found active audio device: \(output.portName)")
63
- return true
64
- }
61
+ if BluetoothAudioRoute.matches(
62
+ name: output.portName,
63
+ portType: output.portType.rawValue,
64
+ target: devicePattern,
65
+ isIOSAppOnMac: ProcessInfo.processInfo.isiOSAppOnMac
66
+ ) {
67
+ Bridge.log("AudioMonitor: ✅ Found active audio device: \(output.portName)")
68
+ return true
65
69
  }
66
70
  }
67
71
 
@@ -283,3 +287,13 @@ class AudioSessionMonitor {
283
287
  }
284
288
  }
285
289
  #endif
290
+
291
+ /// AVAudioSession preserves the route name on iOS-on-Mac but reports the
292
+ /// CoreAudio transport "Bluetooth" instead of iPhone's profile-specific port.
293
+ enum BluetoothAudioRoute {
294
+ static func matches(name: String, portType: String, target: String, isIOSAppOnMac: Bool) -> Bool {
295
+ guard !target.isEmpty, name.localizedCaseInsensitiveContains(target) else { return false }
296
+ return portType == "BluetoothHFP" || portType == "BluetoothA2DPOutput"
297
+ || (isIOSAppOnMac && portType == "Bluetooth")
298
+ }
299
+ }
@@ -0,0 +1,142 @@
1
+ import Foundation
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class AudioRouteObservationTests: XCTestCase {
6
+ @MainActor
7
+ func testInputsBecomingAvailableAfterNotificationAreReconciled() async {
8
+ let center = NotificationCenter()
9
+ let route = Notification.Name("test.audio.route")
10
+ var inputAvailable = false
11
+ var ready = false
12
+ let initialRead = expectation(description: "Initial unavailable input observed")
13
+ let changed = expectation(description: "Delayed input becomes ready without another event")
14
+ var firstRead = true
15
+ let observer = AudioRouteObservation(center: center, names: [route]) {
16
+ if firstRead {
17
+ firstRead = false
18
+ initialRead.fulfill()
19
+ }
20
+ if inputAvailable, !ready { changed.fulfill() }
21
+ ready = inputAvailable
22
+ }
23
+
24
+ center.post(name: route, object: nil)
25
+ await fulfillment(of: [initialRead], timeout: 2)
26
+ XCTAssertFalse(ready)
27
+ inputAvailable = true
28
+ await fulfillment(of: [changed], timeout: 1)
29
+ XCTAssertTrue(ready)
30
+ withExtendedLifetime(observer) {}
31
+ }
32
+
33
+ @MainActor
34
+ func testInitiallyReadyInputCanDisappearDuringSettling() async {
35
+ var inputAvailable = true
36
+ var ready = false
37
+ let disappeared = expectation(description: "Input removal observed during settling")
38
+ let observer = AudioRouteObservation(names: [], settlingDelays: [0.05, 0.05]) {
39
+ if ready, !inputAvailable { disappeared.fulfill() }
40
+ ready = inputAvailable
41
+ }
42
+ observer.refresh()
43
+ XCTAssertTrue(ready)
44
+ inputAvailable = false
45
+ await fulfillment(of: [disappeared], timeout: 2)
46
+ XCTAssertFalse(ready)
47
+ withExtendedLifetime(observer) {}
48
+ }
49
+
50
+ @MainActor
51
+ func testDeviceChangeReplacesPendingChecksAndReadsOnlyCurrentTarget() async {
52
+ var target = "first"
53
+ var availableInputs = Set<String>()
54
+ var observations: [(String, Bool)] = []
55
+ var secondChecks = 0
56
+ let settled = expectation(description: "Replacement window completed")
57
+ let observer = AudioRouteObservation(names: [], settlingDelays: [0.05, 0.05, 0.1]) {
58
+ observations.append((target, availableInputs.contains(target)))
59
+ if target == "second" {
60
+ secondChecks += 1
61
+ if secondChecks == 2 {
62
+ // The old target is available, but its readiness must not leak.
63
+ XCTAssertFalse(observations.contains { $0.1 })
64
+ availableInputs.insert("second")
65
+ }
66
+ if secondChecks == 4 { settled.fulfill() }
67
+ }
68
+ }
69
+ observer.refresh()
70
+ target = "second"
71
+ observer.refresh() // Same entry point used by DeviceStore's device_name change.
72
+ availableInputs.insert("first")
73
+ await fulfillment(of: [settled], timeout: 2)
74
+ XCTAssertEqual(observations.filter { $0.0 == "first" }.count, 1)
75
+ XCTAssertEqual(observations.filter { $0.0 == "second" }.count, 4)
76
+ XCTAssertEqual(observations.last?.0, "second")
77
+ XCTAssertEqual(observations.last?.1, true)
78
+ withExtendedLifetime(observer) {}
79
+ }
80
+
81
+ @MainActor
82
+ func testUnavailableInputStopsRecheckingAfterBoundedWindow() async {
83
+ var checks = 0
84
+ let settled = expectation(description: "Immediate read and two scheduled rechecks completed")
85
+ let extraCheck = expectation(description: "No rechecks beyond the configured window")
86
+ extraCheck.isInverted = true
87
+ let observer = AudioRouteObservation(names: [], settlingDelays: [0.02, 0.02]) {
88
+ checks += 1
89
+ if checks == 3 { settled.fulfill() }
90
+ if checks > 3 { extraCheck.fulfill() }
91
+ }
92
+ observer.refresh()
93
+ await fulfillment(of: [settled], timeout: 2)
94
+ XCTAssertEqual(checks, 3)
95
+ await fulfillment(of: [extraCheck], timeout: 0.1)
96
+ XCTAssertEqual(checks, 3)
97
+ withExtendedLifetime(observer) {}
98
+ }
99
+
100
+ @MainActor
101
+ func testReleasingObserverCancelsPendingRechecks() async throws {
102
+ var checks = 0
103
+ var observer: AudioRouteObservation? = AudioRouteObservation(names: [], settlingDelays: [0.02]) {
104
+ checks += 1
105
+ }
106
+ observer?.refresh()
107
+ XCTAssertEqual(checks, 1)
108
+ observer = nil
109
+ try await Task.sleep(nanoseconds: 100_000_000)
110
+ XCTAssertEqual(checks, 1)
111
+ }
112
+
113
+ func testObservesRouteAndForegroundWithoutStartingAMicrophone() async {
114
+ let center = NotificationCenter()
115
+ let route = Notification.Name("test.audio.route")
116
+ let foreground = Notification.Name("test.audio.foreground")
117
+ let changed = expectation(description: "Both readiness events delivered")
118
+ changed.expectedFulfillmentCount = 2
119
+ let observer = AudioRouteObservation(center: center, names: [route, foreground]) {
120
+ XCTAssertTrue(Thread.isMainThread)
121
+ changed.fulfill()
122
+ }
123
+ center.post(name: route, object: nil)
124
+ center.post(name: foreground, object: nil)
125
+ await fulfillment(of: [changed], timeout: 1)
126
+ withExtendedLifetime(observer) {}
127
+ }
128
+
129
+ func testReleasingObserverStopsReadinessCallbacks() async {
130
+ let center = NotificationCenter()
131
+ let route = Notification.Name("test.audio.route")
132
+ let changed = expectation(description: "Released observer is silent")
133
+ changed.isInverted = true
134
+ var observer: AudioRouteObservation? = AudioRouteObservation(center: center, names: [route]) {
135
+ changed.fulfill()
136
+ }
137
+ XCTAssertNotNil(observer)
138
+ observer = nil
139
+ center.post(name: route, object: nil)
140
+ await fulfillment(of: [changed], timeout: 0.1)
141
+ }
142
+ }
@@ -0,0 +1,37 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class BluetoothAudioRouteTests: XCTestCase {
5
+ func testRecognizesIPhoneProfiles() {
6
+ for port in ["BluetoothHFP", "BluetoothA2DPOutput"] {
7
+ XCTAssertTrue(BluetoothAudioRoute.matches(
8
+ name: "Mentra_Live_03BE", portType: port, target: "03BE", isIOSAppOnMac: false
9
+ ))
10
+ }
11
+ }
12
+
13
+ func testRecognizesObservedMacTransportOnlyOnMac() {
14
+ XCTAssertTrue(BluetoothAudioRoute.matches(
15
+ name: "Mentra_Live_03BE", portType: "Bluetooth", target: "mentra_live_03be", isIOSAppOnMac: true
16
+ ))
17
+ XCTAssertFalse(BluetoothAudioRoute.matches(
18
+ name: "Mentra_Live_03BE", portType: "Bluetooth", target: "03BE", isIOSAppOnMac: false
19
+ ))
20
+ }
21
+
22
+ func testRejectsDifferentGlassesAndMissingIdentity() {
23
+ for target in ["023B", ""] {
24
+ XCTAssertFalse(BluetoothAudioRoute.matches(
25
+ name: "Mentra_Live_03BE", portType: "Bluetooth", target: target, isIOSAppOnMac: true
26
+ ))
27
+ }
28
+ }
29
+
30
+ func testRejectsNonBluetoothDeviceWithMatchingName() {
31
+ for port in ["Speaker", "USBAudio", "BuiltInMic", ""] {
32
+ XCTAssertFalse(BluetoothAudioRoute.matches(
33
+ name: "Mentra_Live_03BE", portType: port, target: "03BE", isIOSAppOnMac: true
34
+ ))
35
+ }
36
+ }
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.1-dev.274",
3
+ "version": "3.2.1-dev.277",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -114,7 +114,7 @@
114
114
  "registry": "https://registry.npmjs.org/"
115
115
  },
116
116
  "dependencies": {
117
- "@mentra/cloud-protocol": "3.2.1-dev.274"
117
+ "@mentra/cloud-protocol": "3.2.1-dev.277"
118
118
  },
119
119
  "devDependencies": {
120
120
  "@types/node": "^25.9.3",
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
12
12
  export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.2.1",
15
- "releaseIdentity": "3.2.1-dev.274",
16
- "releaseSetId": "mentra-3.2.1-dev.274",
17
- "sourceCommit": "fbc4e9c568fd067ce99a485fe385244240136232",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.274.json",
19
- "otaManifestSha256": "78f6de80c6904879ea5f011c076ee99189de70e7309a515d877fddd25b789004"
15
+ "releaseIdentity": "3.2.1-dev.277",
16
+ "releaseSetId": "mentra-3.2.1-dev.277",
17
+ "sourceCommit": "2668275c49728812fea5356f6df3e78e73f1ed87",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json",
19
+ "otaManifestSha256": "527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183"
20
20
  })