@mentra/bluetooth-sdk 3.2.0-dev.266 → 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.
- package/README.md +28 -0
- package/android/lc3Lib/build.gradle +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +35 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +34 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +30 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +20 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +4 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +345 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +13 -0
- package/build/BluetoothSdk.types.d.ts +93 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +32 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +15 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +5 -2
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +6 -2
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +6 -6
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +60 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +27 -0
- package/ios/Source/DeviceManager.swift +52 -50
- package/ios/Source/DeviceStore.swift +6 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
- package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
- package/ios/Source/MentraBluetoothSDK.swift +11 -0
- package/ios/Source/audio/PcmStreamPlayer.swift +12 -0
- package/ios/Source/events/BluetoothEvents.swift +15 -0
- package/ios/Source/services/PhoneMic.swift +1 -3
- package/ios/Source/sgcs/MentraLive.swift +247 -1
- package/ios/Source/sgcs/SGCManager.swift +32 -0
- package/ios/Source/utils/AudioRouteObservation.swift +54 -0
- package/ios/Source/utils/AudioSessionMonitor.swift +20 -6
- package/ios/Tests/AudioRouteObservationTests.swift +142 -0
- package/ios/Tests/BluetoothAudioRouteTests.swift +37 -0
- package/ios/Tests/GalleryServerCommandTests.swift +87 -0
- package/ios/Tests/WearTuningDispatchTests.swift +51 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +99 -0
- package/src/_private/BluetoothSdkModule.ts +64 -1
- package/src/generated/changelogCatalog.ts +6 -2
- package/src/generated/releaseMetadata.ts +6 -6
- package/src/index.ts +1 -0
|
@@ -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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
/// Replace only the radio handoff; exercise the public SDK, device dispatch,
|
|
5
|
+
/// command serialization, receive parser, event bridge, and pending request resolver.
|
|
6
|
+
@MainActor
|
|
7
|
+
private final class GalleryCommandTransport: MentraLive {
|
|
8
|
+
var respond: (([String: Any]) -> [[String: Any]])?
|
|
9
|
+
var requestIds: [String] = []
|
|
10
|
+
|
|
11
|
+
override func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool, requireAck: Bool) {
|
|
12
|
+
XCTAssertEqual(jsonOriginal["type"] as? String, "set_gallery_server_enabled")
|
|
13
|
+
XCTAssertTrue(wakeUp)
|
|
14
|
+
XCTAssertTrue(requireAck)
|
|
15
|
+
do {
|
|
16
|
+
let bytes = try JSONSerialization.data(withJSONObject: jsonOriginal)
|
|
17
|
+
let command = try XCTUnwrap(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
|
|
18
|
+
try requestIds.append(XCTUnwrap(command["request_id"] as? String))
|
|
19
|
+
for response in respond?(command) ?? [] {
|
|
20
|
+
try processReceivedData(JSONSerialization.data(withJSONObject: response))
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
XCTFail("Could not replay gallery command: \(error)")
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@MainActor
|
|
29
|
+
final class GalleryServerCommandTests: XCTestCase {
|
|
30
|
+
func testEnableOfflineEnableAndDisableRoundTripThroughNativeBridge() async throws {
|
|
31
|
+
let previous = DeviceManager.shared.sgc
|
|
32
|
+
let transport = GalleryCommandTransport()
|
|
33
|
+
DeviceManager.shared.sgc = transport
|
|
34
|
+
let sdk = MentraBluetoothSDK()
|
|
35
|
+
defer {
|
|
36
|
+
sdk.invalidate()
|
|
37
|
+
DeviceManager.shared.sgc = previous
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (enabled, listening) in [(true, true), (true, false), (false, false)] {
|
|
41
|
+
transport.respond = { command in
|
|
42
|
+
XCTAssertEqual(command["enabled"] as? Bool, enabled)
|
|
43
|
+
var response: [String: Any] = [
|
|
44
|
+
"type": "settings_ack", "setting": "gallery_server", "status": "applied",
|
|
45
|
+
"request_id": command["request_id"]!, "timestamp": 123,
|
|
46
|
+
"enabled": enabled, "listening": listening,
|
|
47
|
+
]
|
|
48
|
+
if listening { response["url"] = "http://10.1.2.3:8089" }
|
|
49
|
+
// A response to another request must not resolve this call.
|
|
50
|
+
var unrelated = response
|
|
51
|
+
unrelated["request_id"] = "unrelated-request"
|
|
52
|
+
unrelated["enabled"] = !enabled
|
|
53
|
+
return [unrelated, response]
|
|
54
|
+
}
|
|
55
|
+
let ack = try await sdk.setGalleryServerEnabled(enabled)
|
|
56
|
+
XCTAssertEqual(ack.enabled, enabled)
|
|
57
|
+
XCTAssertEqual(ack.listening, listening)
|
|
58
|
+
XCTAssertEqual(ack.url, listening ? "http://10.1.2.3:8089" : nil)
|
|
59
|
+
XCTAssertEqual(ack.requestId, transport.requestIds.last)
|
|
60
|
+
}
|
|
61
|
+
XCTAssertEqual(Set(transport.requestIds).count, 3)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func testStorageErrorRejectsThePublicSdkRequest() async {
|
|
65
|
+
let previous = DeviceManager.shared.sgc
|
|
66
|
+
let transport = GalleryCommandTransport()
|
|
67
|
+
DeviceManager.shared.sgc = transport
|
|
68
|
+
let sdk = MentraBluetoothSDK()
|
|
69
|
+
defer {
|
|
70
|
+
sdk.invalidate()
|
|
71
|
+
DeviceManager.shared.sgc = previous
|
|
72
|
+
}
|
|
73
|
+
transport.respond = { command in
|
|
74
|
+
[["type": "settings_ack", "setting": "gallery_server", "status": "error",
|
|
75
|
+
"request_id": command["request_id"]!, "error_code": "settings_unavailable",
|
|
76
|
+
"error_message": "Could not persist the gallery server setting."]]
|
|
77
|
+
}
|
|
78
|
+
do {
|
|
79
|
+
_ = try await sdk.setGalleryServerEnabled(true)
|
|
80
|
+
XCTFail("A failed save must reject the SDK request")
|
|
81
|
+
} catch let error as BluetoothSdkError {
|
|
82
|
+
XCTAssertEqual(error.code, "settings_unavailable")
|
|
83
|
+
} catch {
|
|
84
|
+
XCTFail("Unexpected error: \(error)")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
/// Recording subclass used to prove wear methods dispatched through an
|
|
5
|
+
/// `SGCManager?` hit MentraLive, not the extension no-op.
|
|
6
|
+
@MainActor
|
|
7
|
+
private final class RecordingMentraLive: MentraLive {
|
|
8
|
+
private(set) var calls: [String] = []
|
|
9
|
+
|
|
10
|
+
override func queryWearState() {
|
|
11
|
+
calls.append("queryWearState")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override func setWearReporting(_ enabled: Bool) {
|
|
15
|
+
calls.append("setWearReporting:\(enabled)")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override func setWearTuning(intervalMs: Int, count: Int, majority: Int) {
|
|
19
|
+
calls.append("setWearTuning:\(intervalMs),\(count),\(majority)")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override func requestWearTuning() {
|
|
23
|
+
calls.append("requestWearTuning")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override func resetWearTuning() {
|
|
27
|
+
calls.append("resetWearTuning")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@MainActor
|
|
32
|
+
final class WearTuningDispatchTests: XCTestCase {
|
|
33
|
+
func testWearMethodsDispatchThroughSGCManagerReference() {
|
|
34
|
+
let live = RecordingMentraLive()
|
|
35
|
+
let sgc: (any SGCManager)? = live
|
|
36
|
+
|
|
37
|
+
sgc?.queryWearState()
|
|
38
|
+
sgc?.setWearReporting(true)
|
|
39
|
+
sgc?.setWearTuning(intervalMs: 300, count: 5, majority: 4)
|
|
40
|
+
sgc?.requestWearTuning()
|
|
41
|
+
sgc?.resetWearTuning()
|
|
42
|
+
|
|
43
|
+
XCTAssertEqual(live.calls, [
|
|
44
|
+
"queryWearState",
|
|
45
|
+
"setWearReporting:true",
|
|
46
|
+
"setWearTuning:300,5,4",
|
|
47
|
+
"requestWearTuning",
|
|
48
|
+
"resetWearTuning",
|
|
49
|
+
])
|
|
50
|
+
}
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/bluetooth-sdk",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.1-beta.312",
|
|
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.
|
|
117
|
+
"@mentra/cloud-protocol": "3.2.1-beta.312"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@types/node": "^25.9.3",
|
|
@@ -481,6 +481,84 @@ export type SwitchStatusEvent = {
|
|
|
481
481
|
timestamp: number
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Mentra Live center-mic gate + ADC gain overrides. Internal SDK surface: this
|
|
486
|
+
* is a super-mode tuning aid, not a public capability.
|
|
487
|
+
*
|
|
488
|
+
* Every field is optional; an omitted field keeps the firmware default. The
|
|
489
|
+
* glasses hold these in RAM only and drop them on disconnect, so the phone
|
|
490
|
+
* re-sends on every connect.
|
|
491
|
+
*/
|
|
492
|
+
export type MicTuning = {
|
|
493
|
+
/** codec_adc_vol index, 0-15. 15 is +32 dB, the top of the table. */
|
|
494
|
+
gain?: number
|
|
495
|
+
/** Gate open / close thresholds, linear RMS on int16 samples. */
|
|
496
|
+
open?: number
|
|
497
|
+
close?: number
|
|
498
|
+
/** Frames of 10 ms. */
|
|
499
|
+
attack?: number
|
|
500
|
+
hang?: number
|
|
501
|
+
/** Thresholds used while the speaker is active or within its hold-off. */
|
|
502
|
+
sp_open?: number
|
|
503
|
+
sp_close?: number
|
|
504
|
+
sp_hold?: number
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* What the glasses report as actually in force, after their own clamping. Use
|
|
509
|
+
* this rather than the requested value when showing applied settings.
|
|
510
|
+
*/
|
|
511
|
+
export type MicTuningStateEvent = MicTuning & {
|
|
512
|
+
type: "mic_tuning_state"
|
|
513
|
+
/** Tuning revision; increments on every accepted set or reset. */
|
|
514
|
+
generation: number
|
|
515
|
+
/** True when any override is in force. */
|
|
516
|
+
overridden: boolean
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Live center-mic level. Disposable: samples are dropped under any pressure. */
|
|
520
|
+
export type MicRmsEvent = {
|
|
521
|
+
type: "mic_rms"
|
|
522
|
+
rms: number
|
|
523
|
+
gateOpen: boolean
|
|
524
|
+
speakerElevated: boolean
|
|
525
|
+
/** Tuning revision the sample was measured under. */
|
|
526
|
+
generation: number
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Mentra Live wear-detection vote. Internal SDK surface: Super Mode only.
|
|
531
|
+
* Values live in RAM on the glasses and reset on disconnect.
|
|
532
|
+
*/
|
|
533
|
+
export type WearTuning = {
|
|
534
|
+
/** Poll period in milliseconds. Firmware clamp: 50–2000. */
|
|
535
|
+
interval?: number
|
|
536
|
+
/** Sliding-window length. Firmware clamp: 3–15. */
|
|
537
|
+
count?: number
|
|
538
|
+
/** Votes needed to flip, either direction. Must be > count/2 and ≤ count. */
|
|
539
|
+
majority?: number
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Current wear vote from sr_wrst, or an unsolicited wear transition. */
|
|
543
|
+
export type WearStateEvent = {
|
|
544
|
+
type: "wear_state"
|
|
545
|
+
worn: boolean
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* What the wear poll loop is actually running. A rejected patch echoes the
|
|
550
|
+
* unchanged values with `accepted: false`.
|
|
551
|
+
*/
|
|
552
|
+
export type WearTuningEvent = WearTuning & {
|
|
553
|
+
type: "wear_tuning"
|
|
554
|
+
enabled: boolean
|
|
555
|
+
interval: number
|
|
556
|
+
count: number
|
|
557
|
+
majority: number
|
|
558
|
+
generation: number
|
|
559
|
+
accepted: boolean
|
|
560
|
+
}
|
|
561
|
+
|
|
484
562
|
export type RgbLedControlResponseEvent =
|
|
485
563
|
| {
|
|
486
564
|
type: "rgb_led_control_response"
|
|
@@ -499,6 +577,7 @@ export type RgbLedControlSuccessResponseEvent = Extract<RgbLedControlResponseEve
|
|
|
499
577
|
export type SettingsAckStatus = "applied" | "ready" | "error" | "failed" | "failure" | "rejected"
|
|
500
578
|
|
|
501
579
|
export type SettingsAckSetting =
|
|
580
|
+
| "gallery_server"
|
|
502
581
|
| "gallery_mode"
|
|
503
582
|
| "button_photo"
|
|
504
583
|
| "button_video_recording"
|
|
@@ -524,6 +603,10 @@ export type SettingsAckEvent = {
|
|
|
524
603
|
fps?: number
|
|
525
604
|
enabled?: boolean
|
|
526
605
|
minutes?: number
|
|
606
|
+
/** Current site-network listener state; present for gallery_server acknowledgements. */
|
|
607
|
+
listening?: boolean
|
|
608
|
+
/** Current HTTP base URL; present for gallery_server only while listening. */
|
|
609
|
+
url?: string
|
|
527
610
|
/** ANR enabled flag; present when setting === "camera_tuning" */
|
|
528
611
|
anr?: boolean
|
|
529
612
|
/** Stock-gain flag; present when setting === "camera_tuning" */
|
|
@@ -1066,6 +1149,10 @@ export type BluetoothSdkModuleEvents = {
|
|
|
1066
1149
|
heartbeat_received: (event: HeartbeatReceivedEvent) => void
|
|
1067
1150
|
swipe_volume_status: (event: SwipeVolumeStatusEvent) => void
|
|
1068
1151
|
switch_status: (event: SwitchStatusEvent) => void
|
|
1152
|
+
mic_tuning_state: (event: MicTuningStateEvent) => void
|
|
1153
|
+
mic_rms: (event: MicRmsEvent) => void
|
|
1154
|
+
wear_state: (event: WearStateEvent) => void
|
|
1155
|
+
wear_tuning: (event: WearTuningEvent) => void
|
|
1069
1156
|
rgb_led_control_response: (event: RgbLedControlResponseEvent) => void
|
|
1070
1157
|
settings_ack: (event: SettingsAckEvent) => void
|
|
1071
1158
|
pair_failure: (event: PairFailureEvent) => void
|
|
@@ -1226,6 +1313,10 @@ export type BluetoothSdkEventMap = {
|
|
|
1226
1313
|
compatible_glasses_search_stop: CompatibleGlassesSearchStopEvent
|
|
1227
1314
|
swipe_volume_status: SwipeVolumeStatusEvent
|
|
1228
1315
|
switch_status: SwitchStatusEvent
|
|
1316
|
+
mic_tuning_state: MicTuningStateEvent
|
|
1317
|
+
mic_rms: MicRmsEvent
|
|
1318
|
+
wear_state: WearStateEvent
|
|
1319
|
+
wear_tuning: WearTuningEvent
|
|
1229
1320
|
rgb_led_control_response: RgbLedControlResponseEvent
|
|
1230
1321
|
settings_ack: SettingsAckEvent
|
|
1231
1322
|
pair_failure: PairFailureEvent
|
|
@@ -1317,6 +1408,12 @@ export interface BluetoothSdkPublicModule {
|
|
|
1317
1408
|
/** Enable or disable Wi-Fi ADB on Mentra Live (no-op on other devices). */
|
|
1318
1409
|
setWifiAdbState(enabled: boolean): Promise<void>
|
|
1319
1410
|
|
|
1411
|
+
/**
|
|
1412
|
+
* Persistently enable/disable unauthenticated HTTP gallery access on the glasses' Wi-Fi.
|
|
1413
|
+
* Default off; survives reconnects/restarts until disabled. Only use on trusted networks.
|
|
1414
|
+
* Returns saved enabled state plus current listening/url. No Wi-Fi means listening=false.
|
|
1415
|
+
*/
|
|
1416
|
+
setGalleryServerEnabled(enabled: boolean): Promise<SettingsAckSuccessEvent>
|
|
1320
1417
|
setGalleryModeEnabled(enabled: boolean): Promise<SettingsAckSuccessEvent>
|
|
1321
1418
|
setVoiceActivityDetectionEnabled(enabled: boolean): Promise<void>
|
|
1322
1419
|
setLoudnessGateEnabled(enabled: boolean): Promise<void>
|
|
@@ -1701,6 +1798,8 @@ export type BluetoothSettingsUpdate = Partial<{
|
|
|
1701
1798
|
gallery_mode: boolean
|
|
1702
1799
|
voice_activity_detection_enabled: boolean
|
|
1703
1800
|
loudness_gate_enabled: boolean
|
|
1801
|
+
/** Effective mic tuning only. `{}` means "reset to firmware defaults". */
|
|
1802
|
+
mic_tuning: MicTuning
|
|
1704
1803
|
button_photo_size: ButtonPhotoSize
|
|
1705
1804
|
button_video_settings: {width: number; height: number; fps: number}
|
|
1706
1805
|
button_video_width: number
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
NativePhoneNotification,
|
|
34
34
|
NativeNotificationConfig,
|
|
35
35
|
NativeNotificationStatus,
|
|
36
|
+
MicTuning,
|
|
36
37
|
ObservableStoreCategory,
|
|
37
38
|
OtaQueryResult,
|
|
38
39
|
OtaStartAckEvent,
|
|
@@ -139,10 +140,41 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
|
|
|
139
140
|
logCurrentWifiFrequency(): Promise<void>
|
|
140
141
|
|
|
141
142
|
// Gallery Commands
|
|
143
|
+
setGalleryServerEnabled(enabled: boolean): Promise<SettingsAckSuccessEvent>
|
|
142
144
|
setGalleryModeEnabled(enabled: boolean): Promise<SettingsAckSuccessEvent>
|
|
143
145
|
setVoiceActivityDetectionEnabled(enabled: boolean): Promise<void>
|
|
144
146
|
/** Mentra Live center-mic loudness / Barrier gate (cs_swit type 10). */
|
|
145
147
|
setLoudnessGateEnabled(enabled: boolean): Promise<void>
|
|
148
|
+
/**
|
|
149
|
+
* Mentra Live mic tuning. `null` clears every override and returns the
|
|
150
|
+
* glasses to firmware defaults.
|
|
151
|
+
*
|
|
152
|
+
* Deliberately absent from the public SDK surface: it is a super-mode
|
|
153
|
+
* calibration aid whose values only make sense next to a live RMS readout.
|
|
154
|
+
*/
|
|
155
|
+
setMicTuning(tuning: MicTuning | null): Promise<void>
|
|
156
|
+
/**
|
|
157
|
+
* Ask the glasses to report the tuning they are actually running. The answer
|
|
158
|
+
* arrives as a `mic_tuning_state` event, which is also emitted unprompted
|
|
159
|
+
* after every set, so a screen that subscribes on mount and calls this on
|
|
160
|
+
* focus never has to guess.
|
|
161
|
+
*/
|
|
162
|
+
requestMicTuningState(): Promise<void>
|
|
163
|
+
/** Enable or disable the `mic_rms` readout. Auto-disabled on disconnect. */
|
|
164
|
+
setMicRmsTelemetry(enabled: boolean): Promise<void>
|
|
165
|
+
/**
|
|
166
|
+
* Mentra Live wear detection. Internal / Super Mode only.
|
|
167
|
+
*
|
|
168
|
+
* Reporting is RAM-only on the glasses and must not go through `cs_swit`
|
|
169
|
+
* type 1 (that bit is NV-backed). `resetWearTuning` both restores the
|
|
170
|
+
* firmware vote defaults and turns reporting off.
|
|
171
|
+
*/
|
|
172
|
+
queryWearState(): Promise<void>
|
|
173
|
+
setWearReporting(enabled: boolean): Promise<void>
|
|
174
|
+
/** Negative values leave that field unchanged. */
|
|
175
|
+
setWearTuning(intervalMs: number, count: number, majority: number): Promise<void>
|
|
176
|
+
requestWearTuning(): Promise<void>
|
|
177
|
+
resetWearTuning(): Promise<void>
|
|
146
178
|
/**
|
|
147
179
|
* @deprecated Sticky action-button photo presets are deprecated. Prefer per-request
|
|
148
180
|
* `requestPhoto(...)` options (e.g. `mode: "text"` for text sensor size/crop).
|
|
@@ -546,6 +578,22 @@ NativeBluetoothSdkModule.setLoudnessGateEnabled = function (enabled: boolean) {
|
|
|
546
578
|
return this.updateBluetoothSettings({loudness_gate_enabled: enabled})
|
|
547
579
|
}
|
|
548
580
|
|
|
581
|
+
NativeBluetoothSdkModule.setMicTuning = function (tuning: MicTuning | null) {
|
|
582
|
+
// `{}` rather than null: the store drops null writes, and the native side
|
|
583
|
+
// reads an empty object as "no overrides" and sends an explicit reset.
|
|
584
|
+
return this.updateBluetoothSettings({mic_tuning: tuning ?? {}})
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const nativeMicModule = NativeBluetoothSdkModule as unknown as Record<string, unknown>
|
|
588
|
+
NativeBluetoothSdkModule.requestMicTuningState = bindNativeMethod<() => Promise<void>>(
|
|
589
|
+
nativeMicModule,
|
|
590
|
+
"requestMicTuningState",
|
|
591
|
+
)
|
|
592
|
+
NativeBluetoothSdkModule.setMicRmsTelemetry = bindNativeMethod<(enabled: boolean) => Promise<void>>(
|
|
593
|
+
nativeMicModule,
|
|
594
|
+
"setMicRmsTelemetry",
|
|
595
|
+
)
|
|
596
|
+
|
|
549
597
|
const nativeSetCameraFov = bindNativeMethod<(fov: CameraFovSetting) => MaybePromise<CameraFovResult>>(
|
|
550
598
|
NativeBluetoothSdkModule as unknown as Record<string, unknown>,
|
|
551
599
|
"setCameraFov",
|
|
@@ -630,7 +678,7 @@ const nativeStopScan = NativeBluetoothSdkModule.stopScan.bind(NativeBluetoothSdk
|
|
|
630
678
|
const activeScanCancellations = new Set<() => Promise<void>>()
|
|
631
679
|
NativeBluetoothSdkModule.stopScan = async function () {
|
|
632
680
|
if (activeScanCancellations.size === 0) return nativeStopScan()
|
|
633
|
-
await Promise.all([...activeScanCancellations].map(cancel => cancel()))
|
|
681
|
+
await Promise.all([...activeScanCancellations].map((cancel) => cancel()))
|
|
634
682
|
}
|
|
635
683
|
|
|
636
684
|
NativeBluetoothSdkModule.scan = async function (modelOrOptions: DeviceModel | ScanOptions, options?: ScanModelOptions) {
|
|
@@ -678,5 +726,20 @@ NativeBluetoothSdkModule.startStream = function (params: StreamStartRequest) {
|
|
|
678
726
|
return nativeStartStream(streamRequestParamsForNative(params) as unknown as StreamStartRequest)
|
|
679
727
|
}
|
|
680
728
|
|
|
729
|
+
const nativeWearModule = NativeBluetoothSdkModule as unknown as Record<string, unknown>
|
|
730
|
+
NativeBluetoothSdkModule.queryWearState = bindNativeMethod<() => Promise<void>>(nativeWearModule, "queryWearState")
|
|
731
|
+
NativeBluetoothSdkModule.setWearReporting = bindNativeMethod<(enabled: boolean) => Promise<void>>(
|
|
732
|
+
nativeWearModule,
|
|
733
|
+
"setWearReporting",
|
|
734
|
+
)
|
|
735
|
+
NativeBluetoothSdkModule.setWearTuning = bindNativeMethod<
|
|
736
|
+
(intervalMs: number, count: number, majority: number) => Promise<void>
|
|
737
|
+
>(nativeWearModule, "setWearTuning")
|
|
738
|
+
NativeBluetoothSdkModule.requestWearTuning = bindNativeMethod<() => Promise<void>>(
|
|
739
|
+
nativeWearModule,
|
|
740
|
+
"requestWearTuning",
|
|
741
|
+
)
|
|
742
|
+
NativeBluetoothSdkModule.resetWearTuning = bindNativeMethod<() => Promise<void>>(nativeWearModule, "resetWearTuning")
|
|
743
|
+
|
|
681
744
|
export default NativeBluetoothSdkModule
|
|
682
745
|
export const BluetoothSdk = NativeBluetoothSdkModule as BluetoothSdkInternalModule
|