@mentra/bluetooth-sdk 3.2.1-beta.455 → 3.2.1-beta.464
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/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +18 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +4 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +95 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
- package/build/BluetoothSdk.types.d.ts +2 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +1 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +36 -26
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/G2.swift +259 -245
- package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/status/DeviceStatus.swift +1 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +337 -0
- package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +3 -0
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Readiness belongs to an authenticated pair and a completed initialization attempt, never to
|
|
4
|
+
/// a gesture from one arm. A generation retires asynchronous setup when either arm is lost.
|
|
5
|
+
struct G2ConnectionSession {
|
|
6
|
+
enum Side: String, CaseIterable {
|
|
7
|
+
case left = "L"
|
|
8
|
+
case right = "R"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private(set) var generation: UInt64 = 0
|
|
12
|
+
private var initialized: Set<Side> = []
|
|
13
|
+
private var authenticatedSides: Set<Side> = []
|
|
14
|
+
private var authStarted = false
|
|
15
|
+
private var setupComplete = false
|
|
16
|
+
|
|
17
|
+
var isReady: Bool {
|
|
18
|
+
initialized.count == 2 && authenticatedSides.count == 2 && setupComplete
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func needsRecovery(attempt: UInt64) -> Bool {
|
|
22
|
+
generation == attempt && !isReady
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
mutating func initialize(_ side: Side) {
|
|
26
|
+
initialized.insert(side)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
mutating func beginAuthentication() -> UInt64? {
|
|
30
|
+
guard initialized.count == 2, !authStarted else { return nil }
|
|
31
|
+
authStarted = true
|
|
32
|
+
return generation
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
mutating func authenticated(_ side: Side, success: Bool) {
|
|
36
|
+
guard authStarted else { return }
|
|
37
|
+
if success { authenticatedSides.insert(side) }
|
|
38
|
+
else { authenticatedSides.remove(side) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
mutating func completeSetup(generation: UInt64) {
|
|
42
|
+
guard self.generation == generation, authStarted else { return }
|
|
43
|
+
setupComplete = true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
mutating func reset() {
|
|
47
|
+
generation &+= 1
|
|
48
|
+
initialized.removeAll()
|
|
49
|
+
authenticatedSides.removeAll()
|
|
50
|
+
authStarted = false
|
|
51
|
+
setupComplete = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
enum G2ConnectionTarget {
|
|
56
|
+
/// A serial-scoped cached UUID identifies an arm even without a new advertisement. Nearby
|
|
57
|
+
/// arms can share the G2_32 name fragment; only an advertisement's serial can select an
|
|
58
|
+
/// uncached arm. Never fall back to names alone or another pair's cached identifiers.
|
|
59
|
+
static func side(
|
|
60
|
+
identifier: UUID, name: String?, advertisedSerial: String?, searchID: String,
|
|
61
|
+
leftUUID: UUID?, rightUUID: UUID?
|
|
62
|
+
) -> G2ConnectionSession.Side? {
|
|
63
|
+
guard !searchID.isEmpty, searchID != "NOT_SET" else { return nil }
|
|
64
|
+
if identifier == leftUUID, identifier == rightUUID { return nil }
|
|
65
|
+
if identifier == leftUUID { return .left }
|
|
66
|
+
if identifier == rightUUID { return .right }
|
|
67
|
+
guard let advertisedSerial, advertisedSerial.contains(searchID), let name else { return nil }
|
|
68
|
+
if name.contains("_L_") { return .left }
|
|
69
|
+
if name.contains("_R_") { return .right }
|
|
70
|
+
return nil
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Normal pairing connects the arms sequentially. Surface a missing arm only after the
|
|
75
|
+
/// same partial link has persisted for three seconds, using monotonic time.
|
|
76
|
+
struct G2PartialConnection {
|
|
77
|
+
static let noticeDelay: TimeInterval = 3
|
|
78
|
+
private(set) var missingSide: G2ConnectionSession.Side?
|
|
79
|
+
private var partialSince: TimeInterval = 0
|
|
80
|
+
|
|
81
|
+
mutating func update(leftConnected: Bool, rightConnected: Bool, now: TimeInterval) {
|
|
82
|
+
let missing: G2ConnectionSession.Side? = leftConnected == rightConnected
|
|
83
|
+
? nil : (leftConnected ? .right : .left)
|
|
84
|
+
if missing != missingSide {
|
|
85
|
+
missingSide = missing
|
|
86
|
+
partialSince = now
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func visibleMissingSide(now: TimeInterval) -> G2ConnectionSession.Side? {
|
|
91
|
+
now - partialSince >= Self.noticeDelay ? missingSide : nil
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var timeoutError: String {
|
|
95
|
+
switch missingSide {
|
|
96
|
+
case .left: "errors:g2LeftArmUnavailable"
|
|
97
|
+
case .right: "errors:g2RightArmUnavailable"
|
|
98
|
+
case nil: "errors:g2ConnectionTimedOut"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -1543,7 +1543,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
1543
1543
|
guard let opusDecoder else { return }
|
|
1544
1544
|
for opusFrame in packet.opusFrames {
|
|
1545
1545
|
if let pcm = opusDecoder.decode(opusFrame), !pcm.isEmpty {
|
|
1546
|
-
DeviceManager.shared.
|
|
1546
|
+
DeviceManager.shared.handleGlassesPcm(pcm)
|
|
1547
1547
|
}
|
|
1548
1548
|
}
|
|
1549
1549
|
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
@MainActor
|
|
5
|
+
final class DeviceManagerReconnectTests: XCTestCase {
|
|
6
|
+
private let requests = ["should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active"]
|
|
7
|
+
|
|
8
|
+
private func withRecordingDevice(_ body: (DeviceManager, DeviceStore) -> Void) {
|
|
9
|
+
let manager = DeviceManager.shared
|
|
10
|
+
let store = DeviceStore.shared
|
|
11
|
+
let saved = ["bluetooth", "glasses"].map { ($0, store.store.getCategory($0)) }
|
|
12
|
+
let previousDevice = manager.sgc
|
|
13
|
+
let previousController = manager.controller
|
|
14
|
+
let previousClock = manager.micWatchdogNow
|
|
15
|
+
manager.micWatchdogNow = { 0 }
|
|
16
|
+
defer {
|
|
17
|
+
store.set("bluetooth", "micEnabled", false)
|
|
18
|
+
manager.updateMicState()
|
|
19
|
+
manager.micWatchdogNow = previousClock
|
|
20
|
+
manager.sgc = previousDevice
|
|
21
|
+
manager.controller = previousController
|
|
22
|
+
for (category, values) in saved {
|
|
23
|
+
for key in store.store.getCategory(category).keys where values[key] == nil {
|
|
24
|
+
store.store.remove(category, key)
|
|
25
|
+
}
|
|
26
|
+
for (key, value) in values {
|
|
27
|
+
store.set(category, key, value)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
manager.controller = nil
|
|
32
|
+
manager.sgc = ReconnectRecordingDevice()
|
|
33
|
+
for key in requests {
|
|
34
|
+
store.set("bluetooth", key, false)
|
|
35
|
+
}
|
|
36
|
+
store.set("bluetooth", "micEnabled", false)
|
|
37
|
+
store.set("bluetooth", "micRanking", [MicTypes.GLASSES_CUSTOM])
|
|
38
|
+
store.set("glasses", "micEnabled", false)
|
|
39
|
+
store.set("glasses", "fullyBooted", false)
|
|
40
|
+
body(manager, store)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func testReconnectRestoresUnchangedAudioRequests() {
|
|
44
|
+
for request in requests {
|
|
45
|
+
withRecordingDevice { manager, store in
|
|
46
|
+
store.apply("bluetooth", request, true)
|
|
47
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, true)
|
|
48
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, true)
|
|
49
|
+
for _ in 0 ..< 3 {
|
|
50
|
+
manager.disconnect()
|
|
51
|
+
XCTAssertEqual(store.get("bluetooth", request) as? Bool, true)
|
|
52
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, false)
|
|
53
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, false)
|
|
54
|
+
let reconnected = ReconnectRecordingDevice()
|
|
55
|
+
manager.sgc = reconnected
|
|
56
|
+
|
|
57
|
+
// Replaying the same consumer request is deduplicated. No test
|
|
58
|
+
// reset of glasses.micEnabled: production teardown must clear it.
|
|
59
|
+
store.apply("bluetooth", request, true)
|
|
60
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, false)
|
|
61
|
+
store.apply("glasses", "fullyBooted", true)
|
|
62
|
+
|
|
63
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, true, request)
|
|
64
|
+
XCTAssertEqual(reconnected.micChanges, [true], request)
|
|
65
|
+
XCTAssertEqual(store.get("bluetooth", "currentMic") as? String, MicTypes.GLASSES_CUSTOM, request)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func testLinkDisconnectRetainsIntentForTheSameCommunicator() {
|
|
72
|
+
withRecordingDevice { manager, store in
|
|
73
|
+
store.apply("glasses", "fullyBooted", true)
|
|
74
|
+
store.apply("bluetooth", "should_send_lc3", true)
|
|
75
|
+
let original = manager.sgc as! ReconnectRecordingDevice
|
|
76
|
+
store.apply("glasses", "fullyBooted", false)
|
|
77
|
+
XCTAssertTrue((manager.sgc as? ReconnectRecordingDevice) === original)
|
|
78
|
+
XCTAssertEqual(store.get("bluetooth", "should_send_lc3") as? Bool, true)
|
|
79
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, true)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func testDiscardingCommunicatorInvalidatesMicCache() {
|
|
84
|
+
withRecordingDevice { manager, store in
|
|
85
|
+
store.apply("bluetooth", "should_send_lc3", true)
|
|
86
|
+
manager.initSGC(manager.sgc!.type)
|
|
87
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, true)
|
|
88
|
+
|
|
89
|
+
// An unsupported model exercises disposal without starting real BLE.
|
|
90
|
+
manager.initSGC("Unavailable test glasses")
|
|
91
|
+
XCTAssertNil(manager.sgc)
|
|
92
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, false)
|
|
93
|
+
XCTAssertEqual(store.get("bluetooth", "should_send_lc3") as? Bool, true)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func testReconnectWithoutAudioRequestsLeavesMicOff() {
|
|
98
|
+
withRecordingDevice { manager, store in
|
|
99
|
+
store.set("glasses", "micEnabled", true)
|
|
100
|
+
manager.disconnect()
|
|
101
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, false)
|
|
102
|
+
let reconnected = ReconnectRecordingDevice()
|
|
103
|
+
manager.sgc = reconnected
|
|
104
|
+
store.apply("glasses", "fullyBooted", true)
|
|
105
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, false)
|
|
106
|
+
XCTAssertEqual(store.get("bluetooth", "currentMic") as? String, "")
|
|
107
|
+
XCTAssertTrue(reconnected.micChanges.isEmpty)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func testWatchdogRetriesWhenFirstPacketNeverArrivesAndLimitsRetries() {
|
|
112
|
+
withRecordingDevice { manager, store in
|
|
113
|
+
var now: TimeInterval = 0
|
|
114
|
+
manager.micWatchdogNow = { now }
|
|
115
|
+
store.apply("glasses", "fullyBooted", true)
|
|
116
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
117
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
118
|
+
now = 4
|
|
119
|
+
manager.checkAndReinitGlassesMic()
|
|
120
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
121
|
+
now = 5
|
|
122
|
+
manager.checkAndReinitGlassesMic()
|
|
123
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
124
|
+
now = 5.1
|
|
125
|
+
manager.checkAndReinitGlassesMic()
|
|
126
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
127
|
+
now = 10
|
|
128
|
+
manager.checkAndReinitGlassesMic()
|
|
129
|
+
XCTAssertEqual(device.micChanges, [true, true, true])
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func testDecodedGlassesAudioKeepsWatchdogHealthyAndMissingPacketsAfterwardRetry() {
|
|
134
|
+
withRecordingDevice { manager, store in
|
|
135
|
+
var now: TimeInterval = 0
|
|
136
|
+
manager.micWatchdogNow = { now }
|
|
137
|
+
store.apply("glasses", "fullyBooted", true)
|
|
138
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
139
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
140
|
+
// This is the decoded-audio entry point used by Nimo and AR99.
|
|
141
|
+
for tick in 1 ... 10 {
|
|
142
|
+
now = TimeInterval(tick * 10)
|
|
143
|
+
manager.handleGlassesPcm(Data([0, 0]))
|
|
144
|
+
manager.checkAndReinitGlassesMic()
|
|
145
|
+
}
|
|
146
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
147
|
+
now = 104
|
|
148
|
+
manager.checkAndReinitGlassesMic()
|
|
149
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
150
|
+
now = 105
|
|
151
|
+
manager.checkAndReinitGlassesMic()
|
|
152
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func testLc3GlassesAudioKeepsWatchdogHealthy() {
|
|
157
|
+
withRecordingDevice { manager, store in
|
|
158
|
+
var now: TimeInterval = 0
|
|
159
|
+
manager.micWatchdogNow = { now }
|
|
160
|
+
store.apply("glasses", "fullyBooted", true)
|
|
161
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
162
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
163
|
+
let lc3 = manager.lc3Converter!.encode(Data(repeating: 0, count: 320), frameSize: 40) as Data
|
|
164
|
+
XCTAssertEqual(lc3.count, 40)
|
|
165
|
+
// G2 and Live deliver LC3 through this entry point, not decoded PCM.
|
|
166
|
+
for tick in 1 ... 10 {
|
|
167
|
+
now = TimeInterval(tick * 10)
|
|
168
|
+
manager.handleGlassesMicData(lc3, 40)
|
|
169
|
+
manager.checkAndReinitGlassesMic()
|
|
170
|
+
}
|
|
171
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func testPhonePcmAndEmptyGlassesPcmCannotHideAMissingGlassesStream() {
|
|
176
|
+
withRecordingDevice { manager, store in
|
|
177
|
+
var now: TimeInterval = 0
|
|
178
|
+
manager.micWatchdogNow = { now }
|
|
179
|
+
store.apply("glasses", "fullyBooted", true)
|
|
180
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
181
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
182
|
+
now = 5
|
|
183
|
+
manager.handlePcm(Data([0, 0]))
|
|
184
|
+
manager.handleGlassesPcm(Data())
|
|
185
|
+
manager.checkAndReinitGlassesMic()
|
|
186
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func testWatchdogRespectsSuspensionAndGivesResumeAFreshDeadline() {
|
|
191
|
+
withRecordingDevice { manager, store in
|
|
192
|
+
var now: TimeInterval = 0
|
|
193
|
+
manager.micWatchdogNow = { now }
|
|
194
|
+
store.apply("glasses", "fullyBooted", true)
|
|
195
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
196
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
197
|
+
device.isMicSuspendedForAudio = true
|
|
198
|
+
now = 100
|
|
199
|
+
manager.checkAndReinitGlassesMic()
|
|
200
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
201
|
+
device.isMicSuspendedForAudio = false
|
|
202
|
+
now = 200
|
|
203
|
+
manager.checkAndReinitGlassesMic()
|
|
204
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
205
|
+
now = 205
|
|
206
|
+
manager.checkAndReinitGlassesMic()
|
|
207
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
208
|
+
|
|
209
|
+
store.apply("bluetooth", "should_send_pcm", false)
|
|
210
|
+
// A late firmware ACK can leave the shared cache true after demand ends.
|
|
211
|
+
store.set("glasses", "micEnabled", true)
|
|
212
|
+
now = 300
|
|
213
|
+
manager.checkAndReinitGlassesMic()
|
|
214
|
+
XCTAssertEqual(device.micChanges, [true, true, false])
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func testWatchdogRespectsOwnAppPlaybackAndPhoneRoute() {
|
|
219
|
+
withRecordingDevice { manager, store in
|
|
220
|
+
var now: TimeInterval = 0
|
|
221
|
+
manager.micWatchdogNow = { now }
|
|
222
|
+
let monitor = PhoneAudioMonitor.getInstance()
|
|
223
|
+
let wasPlaying = monitor.isOwnAppAudioPlaying()
|
|
224
|
+
defer { monitor.setOwnAppAudioPlaying(wasPlaying) }
|
|
225
|
+
monitor.setOwnAppAudioPlaying(false)
|
|
226
|
+
store.apply("glasses", "fullyBooted", true)
|
|
227
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
228
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
229
|
+
monitor.setOwnAppAudioPlaying(true)
|
|
230
|
+
now = 100
|
|
231
|
+
manager.checkAndReinitGlassesMic()
|
|
232
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
233
|
+
monitor.setOwnAppAudioPlaying(false)
|
|
234
|
+
store.set("bluetooth", "currentMic", MicTypes.PHONE_INTERNAL)
|
|
235
|
+
now = 200
|
|
236
|
+
manager.checkAndReinitGlassesMic()
|
|
237
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
func testWatchdogDeadlineBelongsToTheCurrentConnection() {
|
|
242
|
+
withRecordingDevice { manager, store in
|
|
243
|
+
var now: TimeInterval = 0
|
|
244
|
+
manager.micWatchdogNow = { now }
|
|
245
|
+
store.apply("glasses", "fullyBooted", true)
|
|
246
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
247
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
248
|
+
store.apply("glasses", "fullyBooted", false)
|
|
249
|
+
now = 100
|
|
250
|
+
manager.checkAndReinitGlassesMic()
|
|
251
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
252
|
+
store.apply("glasses", "fullyBooted", true)
|
|
253
|
+
manager.checkAndReinitGlassesMic()
|
|
254
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
255
|
+
now = 105
|
|
256
|
+
manager.checkAndReinitGlassesMic()
|
|
257
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@MainActor
|
|
263
|
+
private final class ReconnectRecordingDevice: SGCManager {
|
|
264
|
+
var type = "Reconnect test glasses"
|
|
265
|
+
let hasMic = true
|
|
266
|
+
let showConnectionConfirmation = false
|
|
267
|
+
var micChanges: [Bool] = []
|
|
268
|
+
var isMicSuspendedForAudio = false
|
|
269
|
+
|
|
270
|
+
func clearSceneElements(_: [String]) async {}
|
|
271
|
+
func sendTextWall(_: String) async {}
|
|
272
|
+
func applySceneFrame(_: SceneFrame) async {}
|
|
273
|
+
func clearDisplay() {}
|
|
274
|
+
|
|
275
|
+
func setMicEnabled(_ enabled: Bool) {
|
|
276
|
+
micChanges.append(enabled)
|
|
277
|
+
DeviceStore.shared.set("glasses", "micEnabled", enabled)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func sortMicRanking(list: [String]) -> [String] {
|
|
281
|
+
list
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {}
|
|
285
|
+
func requestPhoto(_: PhotoRequest) {}
|
|
286
|
+
func startStream(_: [String: Any]) {}
|
|
287
|
+
func stopStream() {}
|
|
288
|
+
func sendStreamKeepAlive(_: [String: Any]) {}
|
|
289
|
+
func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) {}
|
|
290
|
+
func stopVideoRecording(requestId _: String) {}
|
|
291
|
+
func sendButtonPhotoSettings() {}
|
|
292
|
+
func sendButtonVideoRecordingSettings() {}
|
|
293
|
+
func sendCameraFovSetting() {}
|
|
294
|
+
func sendButtonMaxRecordingTime() {}
|
|
295
|
+
func setBrightness(_: Int, autoMode _: Bool) {}
|
|
296
|
+
func sendText(_: String) async {}
|
|
297
|
+
func sendDoubleTextWall(_: String, _: String) async {}
|
|
298
|
+
func displayBitmap(base64ImageData _: String, x _: Int32?, y _: Int32?, width _: Int32?, height _: Int32?) async -> Bool {
|
|
299
|
+
false
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func showDashboard() {}
|
|
303
|
+
func setDashboardPosition(_: Int, _: Int) {}
|
|
304
|
+
func setHeadUpAngle(_: Int) {}
|
|
305
|
+
func getBatteryStatus() {}
|
|
306
|
+
func setSilentMode(_: Bool) {}
|
|
307
|
+
func exit() {}
|
|
308
|
+
func sendShutdown() {}
|
|
309
|
+
func sendReboot() {}
|
|
310
|
+
func sendRgbLedControl(requestId _: String, packageName _: String?, action _: String, color _: String?, onDurationMs _: Int, offDurationMs _: Int, count _: Int) {}
|
|
311
|
+
func disconnect() {}
|
|
312
|
+
func forget() {}
|
|
313
|
+
func findCompatibleDevices() {}
|
|
314
|
+
func stopScan() {}
|
|
315
|
+
func connectById(_: String) {}
|
|
316
|
+
func getConnectedBluetoothName() -> String? {
|
|
317
|
+
nil
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
func cleanup() {}
|
|
321
|
+
func ping() {}
|
|
322
|
+
func dbg1() {}
|
|
323
|
+
func dbg2() {}
|
|
324
|
+
func connectController() {}
|
|
325
|
+
func disconnectController() {}
|
|
326
|
+
func requestWifiScan(scanId _: String?) {}
|
|
327
|
+
func sendWifiCredentials(_: String, _: String) {}
|
|
328
|
+
func forgetWifiNetwork(_: String) {}
|
|
329
|
+
func sendHotspotState(_: Bool) {}
|
|
330
|
+
func sendUserEmailToGlasses(_: String) {}
|
|
331
|
+
func sendOtaStart(otaVersionUrl _: String?) {}
|
|
332
|
+
func sendOtaQueryStatus() {}
|
|
333
|
+
func queryGalleryStatus() {}
|
|
334
|
+
func sendGalleryMode() {}
|
|
335
|
+
func requestVersionInfo() {}
|
|
336
|
+
func sendIncidentId(_: String, apiBaseUrl _: String?) {}
|
|
337
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class G2ConnectionSessionTests: XCTestCase {
|
|
5
|
+
func testEitherArmAloneCannotStartAuthenticationOrBecomeReady() {
|
|
6
|
+
for side in G2ConnectionSession.Side.allCases {
|
|
7
|
+
var session = G2ConnectionSession()
|
|
8
|
+
session.initialize(side)
|
|
9
|
+
XCTAssertNil(session.beginAuthentication())
|
|
10
|
+
session.authenticated(side, success: true)
|
|
11
|
+
session.completeSetup(generation: session.generation)
|
|
12
|
+
XCTAssertFalse(session.isReady)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func testReadinessWaitsForBothAuthenticationResponsesAndCompletedSetup() throws {
|
|
17
|
+
for completeSetupFirst in [false, true] {
|
|
18
|
+
var session = G2ConnectionSession()
|
|
19
|
+
session.initialize(.right)
|
|
20
|
+
session.initialize(.left)
|
|
21
|
+
let generation = try XCTUnwrap(session.beginAuthentication())
|
|
22
|
+
XCTAssertNil(session.beginAuthentication(), "Duplicate service callbacks must not restart setup")
|
|
23
|
+
if completeSetupFirst { session.completeSetup(generation: generation) }
|
|
24
|
+
session.authenticated(.left, success: true)
|
|
25
|
+
session.authenticated(.right, success: false)
|
|
26
|
+
XCTAssertFalse(session.isReady)
|
|
27
|
+
session.authenticated(.right, success: true)
|
|
28
|
+
XCTAssertEqual(session.isReady, completeSetupFirst)
|
|
29
|
+
session.completeSetup(generation: generation)
|
|
30
|
+
XCTAssertTrue(session.isReady)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func testDisconnectRetiresAuthenticationAndSuspendedSetup() throws {
|
|
35
|
+
var session = G2ConnectionSession()
|
|
36
|
+
session.initialize(.left)
|
|
37
|
+
session.initialize(.right)
|
|
38
|
+
let oldGeneration = try XCTUnwrap(session.beginAuthentication())
|
|
39
|
+
session.authenticated(.left, success: true)
|
|
40
|
+
session.reset()
|
|
41
|
+
session.authenticated(.right, success: true)
|
|
42
|
+
session.completeSetup(generation: oldGeneration)
|
|
43
|
+
XCTAssertFalse(session.isReady)
|
|
44
|
+
|
|
45
|
+
session.initialize(.left)
|
|
46
|
+
session.initialize(.right)
|
|
47
|
+
let newGeneration = try XCTUnwrap(session.beginAuthentication())
|
|
48
|
+
session.authenticated(.left, success: true)
|
|
49
|
+
session.authenticated(.right, success: true)
|
|
50
|
+
session.completeSetup(generation: oldGeneration)
|
|
51
|
+
XCTAssertFalse(session.isReady, "Old setup cannot complete the replacement attempt")
|
|
52
|
+
session.completeSetup(generation: newGeneration)
|
|
53
|
+
XCTAssertTrue(session.isReady)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func testAttemptDeadlineCoversBothPartialPairsAndIncompleteAuthentication() throws {
|
|
57
|
+
for sides: [G2ConnectionSession.Side] in [[], [.left], [.right], [.left, .right]] {
|
|
58
|
+
var session = G2ConnectionSession()
|
|
59
|
+
for side in sides {
|
|
60
|
+
session.initialize(side)
|
|
61
|
+
}
|
|
62
|
+
_ = session.beginAuthentication()
|
|
63
|
+
let attempt = session.generation
|
|
64
|
+
XCTAssertTrue(session.needsRecovery(attempt: attempt))
|
|
65
|
+
session.reset()
|
|
66
|
+
XCTAssertFalse(session.needsRecovery(attempt: attempt), "A retired deadline cannot reset a new attempt")
|
|
67
|
+
}
|
|
68
|
+
var ready = G2ConnectionSession()
|
|
69
|
+
ready.initialize(.left)
|
|
70
|
+
ready.initialize(.right)
|
|
71
|
+
let attempt = try XCTUnwrap(ready.beginAuthentication())
|
|
72
|
+
ready.authenticated(.left, success: true)
|
|
73
|
+
ready.authenticated(.right, success: true)
|
|
74
|
+
ready.completeSetup(generation: attempt)
|
|
75
|
+
XCTAssertFalse(ready.needsRecovery(attempt: attempt))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func testCachedArmsCanReconnectIndependentlyWithoutAdvertisements() {
|
|
79
|
+
let left = UUID(), right = UUID()
|
|
80
|
+
XCTAssertEqual(target(left, left: left), .left)
|
|
81
|
+
XCTAssertEqual(target(right, right: right), .right)
|
|
82
|
+
XCTAssertNil(target(UUID(), left: left, right: right))
|
|
83
|
+
XCTAssertNil(target(right, left: right, right: right), "Ambiguous cache must not bind one arm twice")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func testOnlySelectedSerialCanFillTheMissingArm() {
|
|
87
|
+
let id = UUID()
|
|
88
|
+
XCTAssertEqual(target(id, name: "Even G2_32_L_123456", serial: "S211SELECTED"), .left)
|
|
89
|
+
XCTAssertNil(target(id, name: "Even G2_32_L_123456", serial: "S211OTHER"))
|
|
90
|
+
XCTAssertNil(target(id, name: "Even G2_32_R_123456"))
|
|
91
|
+
XCTAssertNil(target(id, right: id, searchID: "NOT_SET"))
|
|
92
|
+
XCTAssertNil(target(id, right: id, searchID: ""))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func target(
|
|
96
|
+
_ id: UUID, name: String? = nil, serial: String? = nil,
|
|
97
|
+
left: UUID? = nil, right: UUID? = nil, searchID: String = "S211SELECTED"
|
|
98
|
+
) -> G2ConnectionSession.Side? {
|
|
99
|
+
G2ConnectionTarget.side(identifier: id, name: name, advertisedSerial: serial,
|
|
100
|
+
searchID: searchID, leftUUID: left, rightUUID: right)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@MainActor
|
|
105
|
+
final class G2PrematureReadinessTests: XCTestCase {
|
|
106
|
+
func testSystemExitAndTouchBeforePairInitializationDoNotPublishReady() {
|
|
107
|
+
let store = DeviceStore.shared.store
|
|
108
|
+
let connected = store.get("glasses", "connected")
|
|
109
|
+
let booted = store.get("glasses", "fullyBooted")
|
|
110
|
+
defer {
|
|
111
|
+
if let connected { store.set("glasses", "connected", connected) }
|
|
112
|
+
if let booted { store.set("glasses", "fullyBooted", booted) }
|
|
113
|
+
}
|
|
114
|
+
store.set("glasses", "connected", false)
|
|
115
|
+
store.set("glasses", "fullyBooted", false)
|
|
116
|
+
let g2 = G2()
|
|
117
|
+
// SendDeviceEvent.SysEvent { eventType: systemExit }, as observed on the right arm.
|
|
118
|
+
g2.handleTouchEvent(Data([0x1A, 0x02, 0x08, 0x07]))
|
|
119
|
+
XCTAssertEqual(store.get("glasses", "connected") as? Bool, false)
|
|
120
|
+
XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
|
|
121
|
+
g2.handleTouchEvent(Data([0x1A, 0x02, 0x08, 0x00]))
|
|
122
|
+
XCTAssertEqual(store.get("glasses", "connected") as? Bool, false)
|
|
123
|
+
XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
final class G2PartialConnectionTests: XCTestCase {
|
|
128
|
+
func testEitherPartialLinkStaysQuietForThreeSeconds() {
|
|
129
|
+
for leftConnected in [false, true] {
|
|
130
|
+
var progress = G2PartialConnection()
|
|
131
|
+
progress.update(leftConnected: leftConnected, rightConnected: !leftConnected, now: 10)
|
|
132
|
+
XCTAssertNil(progress.visibleMissingSide(now: 10))
|
|
133
|
+
XCTAssertNil(progress.visibleMissingSide(now: 12.999))
|
|
134
|
+
// Duplicate observations must not restart the grace period.
|
|
135
|
+
progress.update(leftConnected: leftConnected, rightConnected: !leftConnected, now: 12)
|
|
136
|
+
XCTAssertEqual(progress.visibleMissingSide(now: 13), leftConnected ? .right : .left)
|
|
137
|
+
XCTAssertEqual(progress.timeoutError, leftConnected ? "errors:g2RightArmUnavailable" : "errors:g2LeftArmUnavailable")
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func testNormalSequentialPairingAndDisconnectedStateNeverShowMissingArm() {
|
|
142
|
+
var progress = G2PartialConnection()
|
|
143
|
+
progress.update(leftConnected: false, rightConnected: true, now: 10)
|
|
144
|
+
progress.update(leftConnected: true, rightConnected: true, now: 12)
|
|
145
|
+
XCTAssertNil(progress.visibleMissingSide(now: 15))
|
|
146
|
+
progress.update(leftConnected: false, rightConnected: false, now: 16)
|
|
147
|
+
XCTAssertNil(progress.visibleMissingSide(now: 20))
|
|
148
|
+
XCTAssertEqual(progress.timeoutError, "errors:g2ConnectionTimedOut")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func testPeerArrivalClearsVisibleNoticeAndNewPartialLinkGetsOwnGracePeriod() {
|
|
152
|
+
var progress = G2PartialConnection()
|
|
153
|
+
progress.update(leftConnected: true, rightConnected: false, now: 10)
|
|
154
|
+
XCTAssertEqual(progress.visibleMissingSide(now: 13), .right)
|
|
155
|
+
progress.update(leftConnected: true, rightConnected: true, now: 14)
|
|
156
|
+
XCTAssertNil(progress.visibleMissingSide(now: 14))
|
|
157
|
+
progress.update(leftConnected: false, rightConnected: true, now: 15)
|
|
158
|
+
XCTAssertNil(progress.visibleMissingSide(now: 17))
|
|
159
|
+
XCTAssertEqual(progress.visibleMissingSide(now: 18), .left)
|
|
160
|
+
progress = G2PartialConnection()
|
|
161
|
+
XCTAssertNil(progress.visibleMissingSide(now: 100))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func testStatusSnapshotAndUpdateCarryNoticeAndDisconnectClearsIt() throws {
|
|
165
|
+
let status = GlassesStatus(values: ["g2MissingArm": "left"])
|
|
166
|
+
XCTAssertEqual(status.dictionary["g2MissingArm"] as? String, "left")
|
|
167
|
+
XCTAssertEqual(GlassesStatusUpdate(values: ["g2MissingArm": "right"]).dictionary["g2MissingArm"] as? String, "right")
|
|
168
|
+
let disconnected = status.disconnected().dictionary
|
|
169
|
+
XCTAssertTrue(disconnected["g2MissingArm"] is NSNull)
|
|
170
|
+
XCTAssertNoThrow(try JSONSerialization.data(withJSONObject: disconnected))
|
|
171
|
+
}
|
|
172
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/bluetooth-sdk",
|
|
3
|
-
"version": "3.2.1-beta.
|
|
3
|
+
"version": "3.2.1-beta.464",
|
|
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-beta.
|
|
117
|
+
"@mentra/cloud-protocol": "3.2.1-beta.464"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@types/node": "^25.9.3",
|
|
@@ -82,6 +82,7 @@ export function isBusyGlassesConnectionStatus(status: GlassesConnectionStatus):
|
|
|
82
82
|
export function createDisconnectedGlassesStatus(): Partial<GlassesStatus> {
|
|
83
83
|
return {
|
|
84
84
|
connection: {state: "disconnected"},
|
|
85
|
+
g2MissingArm: null,
|
|
85
86
|
hotspot: {state: "disabled"},
|
|
86
87
|
voiceActivityDetectionEnabled: DEFAULT_VOICE_ACTIVITY_DETECTION_ENABLED,
|
|
87
88
|
wifi: {state: "disconnected"},
|
|
@@ -1613,6 +1614,8 @@ export interface OtaProgress {
|
|
|
1613
1614
|
}
|
|
1614
1615
|
|
|
1615
1616
|
export interface GlassesStatus {
|
|
1617
|
+
/** iOS G2 arm still disconnected after a three-second partial link; cleared on reset or both links. */
|
|
1618
|
+
g2MissingArm?: "left" | "right" | null
|
|
1616
1619
|
// state:
|
|
1617
1620
|
connection: GlassesConnectionStatus
|
|
1618
1621
|
micEnabled: boolean
|
|
@@ -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-beta.
|
|
16
|
-
"releaseSetId": "mentra-3.2.1-beta.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.2.1-beta.464",
|
|
16
|
+
"releaseSetId": "mentra-3.2.1-beta.464",
|
|
17
|
+
"sourceCommit": "02a42222a7f1d3807325cf6c8d081b8738aaf213",
|
|
18
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.464.json",
|
|
19
|
+
"otaManifestSha256": "a55ad585f02ca9c63f7a8dde6f845516bdc1dcba36dad930001df69917856c15"
|
|
20
20
|
})
|