@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
- package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
- package/build/BluetoothSdk.types.d.ts +13 -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/_private/BluetoothSdkModule.d.ts +4 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +3 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +2 -2
- package/build/generated/changelogCatalog.js +2 -2
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- 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 +9 -0
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothBackgroundMode.swift +32 -0
- package/ios/Source/BluetoothSdkDefaults.swift +6 -1
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +96 -63
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +18 -0
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/requests/DisplayRequests.swift +1 -1
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/G1.swift +68 -38
- package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
- package/ios/Source/sgcs/G2.swift +261 -247
- package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
- package/ios/Source/sgcs/Mach1.swift +19 -7
- package/ios/Source/sgcs/MentraLive.swift +201 -156
- package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
- package/ios/Source/sgcs/MentraNex.swift +8 -31
- package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
- package/ios/Source/sgcs/Nimo.swift +280 -408
- package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
- package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
- package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
- package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
- package/ios/Source/sgcs/SGCManager.swift +24 -0
- package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
- package/ios/Source/status/DeviceStatus.swift +1 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
- package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
- package/ios/Source/utils/G2Text.swift +11 -0
- package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
- package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
- package/ios/Tests/DashboardRenderingTests.swift +31 -0
- package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
- package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
- package/ios/Tests/G1PairingTests.swift +162 -0
- package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
- package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
- package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
- package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
- package/ios/Tests/NimoDiscoveryTests.swift +79 -0
- package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
- package/ios/Tests/VersionInfoCommandTests.swift +53 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
- package/package.json +2 -2
- package/plugin/build/withIos.d.ts +2 -0
- package/plugin/build/withIos.js +12 -1
- package/src/BluetoothSdk.types.ts +14 -0
- package/src/_private/BluetoothSdkModule.ts +8 -0
- package/src/generated/changelogCatalog.ts +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +1 -0
|
@@ -0,0 +1,362 @@
|
|
|
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
|
+
func testDelayedReconnectReplayPreservesNimoDepthEndpoints() {
|
|
9
|
+
for depth in [0, 10] {
|
|
10
|
+
withRecordingDevice { manager, store in
|
|
11
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
12
|
+
device.type = DeviceTypes.NIMO
|
|
13
|
+
store.set("bluetooth", "dashboard_height", 7)
|
|
14
|
+
store.set("bluetooth", "dashboard_depth", depth)
|
|
15
|
+
let replayed = expectation(description: "Delayed NIMO depth \(depth)")
|
|
16
|
+
replayed.assertForOverFulfill = false
|
|
17
|
+
device.onPosition = { replayed.fulfill() }
|
|
18
|
+
manager.handleDeviceReady()
|
|
19
|
+
XCTAssertTrue(device.positions.isEmpty)
|
|
20
|
+
wait(for: [replayed], timeout: 3)
|
|
21
|
+
XCTAssertEqual(device.positions.last?.0, 7)
|
|
22
|
+
XCTAssertEqual(device.positions.last?.1, depth)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private func withRecordingDevice(_ body: (DeviceManager, DeviceStore) -> Void) {
|
|
28
|
+
let manager = DeviceManager.shared
|
|
29
|
+
let store = DeviceStore.shared
|
|
30
|
+
let saved = ["bluetooth", "glasses"].map { ($0, store.store.getCategory($0)) }
|
|
31
|
+
let previousDevice = manager.sgc
|
|
32
|
+
let previousController = manager.controller
|
|
33
|
+
let previousClock = manager.micWatchdogNow
|
|
34
|
+
manager.micWatchdogNow = { 0 }
|
|
35
|
+
defer {
|
|
36
|
+
store.set("bluetooth", "micEnabled", false)
|
|
37
|
+
manager.updateMicState()
|
|
38
|
+
manager.micWatchdogNow = previousClock
|
|
39
|
+
manager.sgc = previousDevice
|
|
40
|
+
manager.controller = previousController
|
|
41
|
+
for (category, values) in saved {
|
|
42
|
+
for key in store.store.getCategory(category).keys where values[key] == nil {
|
|
43
|
+
store.store.remove(category, key)
|
|
44
|
+
}
|
|
45
|
+
for (key, value) in values {
|
|
46
|
+
store.set(category, key, value)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
manager.controller = nil
|
|
51
|
+
manager.sgc = ReconnectRecordingDevice()
|
|
52
|
+
for key in requests {
|
|
53
|
+
store.set("bluetooth", key, false)
|
|
54
|
+
}
|
|
55
|
+
store.set("bluetooth", "micEnabled", false)
|
|
56
|
+
store.set("bluetooth", "micRanking", [MicTypes.GLASSES_CUSTOM])
|
|
57
|
+
store.set("glasses", "micEnabled", false)
|
|
58
|
+
store.set("glasses", "fullyBooted", false)
|
|
59
|
+
body(manager, store)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func testReconnectRestoresUnchangedAudioRequests() {
|
|
63
|
+
for request in requests {
|
|
64
|
+
withRecordingDevice { manager, store in
|
|
65
|
+
store.apply("bluetooth", request, true)
|
|
66
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, true)
|
|
67
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, true)
|
|
68
|
+
for _ in 0 ..< 3 {
|
|
69
|
+
manager.disconnect()
|
|
70
|
+
XCTAssertEqual(store.get("bluetooth", request) as? Bool, true)
|
|
71
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, false)
|
|
72
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, false)
|
|
73
|
+
let reconnected = ReconnectRecordingDevice()
|
|
74
|
+
manager.sgc = reconnected
|
|
75
|
+
|
|
76
|
+
// Replaying the same consumer request is deduplicated. No test
|
|
77
|
+
// reset of glasses.micEnabled: production teardown must clear it.
|
|
78
|
+
store.apply("bluetooth", request, true)
|
|
79
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, false)
|
|
80
|
+
store.apply("glasses", "fullyBooted", true)
|
|
81
|
+
|
|
82
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, true, request)
|
|
83
|
+
XCTAssertEqual(reconnected.micChanges, [true], request)
|
|
84
|
+
XCTAssertEqual(store.get("bluetooth", "currentMic") as? String, MicTypes.GLASSES_CUSTOM, request)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func testLinkDisconnectRetainsIntentForTheSameCommunicator() {
|
|
91
|
+
withRecordingDevice { manager, store in
|
|
92
|
+
store.apply("glasses", "fullyBooted", true)
|
|
93
|
+
store.apply("bluetooth", "should_send_lc3", true)
|
|
94
|
+
let original = manager.sgc as! ReconnectRecordingDevice
|
|
95
|
+
store.apply("glasses", "fullyBooted", false)
|
|
96
|
+
XCTAssertTrue((manager.sgc as? ReconnectRecordingDevice) === original)
|
|
97
|
+
XCTAssertEqual(store.get("bluetooth", "should_send_lc3") as? Bool, true)
|
|
98
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, true)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func testDiscardingCommunicatorInvalidatesMicCache() {
|
|
103
|
+
withRecordingDevice { manager, store in
|
|
104
|
+
store.apply("bluetooth", "should_send_lc3", true)
|
|
105
|
+
manager.initSGC(manager.sgc!.type)
|
|
106
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, true)
|
|
107
|
+
|
|
108
|
+
// An unsupported model exercises disposal without starting real BLE.
|
|
109
|
+
manager.initSGC("Unavailable test glasses")
|
|
110
|
+
XCTAssertNil(manager.sgc)
|
|
111
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, false)
|
|
112
|
+
XCTAssertEqual(store.get("bluetooth", "should_send_lc3") as? Bool, true)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func testReconnectWithoutAudioRequestsLeavesMicOff() {
|
|
117
|
+
withRecordingDevice { manager, store in
|
|
118
|
+
store.set("glasses", "micEnabled", true)
|
|
119
|
+
manager.disconnect()
|
|
120
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, false)
|
|
121
|
+
let reconnected = ReconnectRecordingDevice()
|
|
122
|
+
manager.sgc = reconnected
|
|
123
|
+
store.apply("glasses", "fullyBooted", true)
|
|
124
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, false)
|
|
125
|
+
XCTAssertEqual(store.get("bluetooth", "currentMic") as? String, "")
|
|
126
|
+
XCTAssertTrue(reconnected.micChanges.isEmpty)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func testWatchdogRetriesWhenFirstPacketNeverArrivesAndLimitsRetries() {
|
|
131
|
+
withRecordingDevice { manager, store in
|
|
132
|
+
var now: TimeInterval = 0
|
|
133
|
+
manager.micWatchdogNow = { now }
|
|
134
|
+
store.apply("glasses", "fullyBooted", true)
|
|
135
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
136
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
137
|
+
now = 4
|
|
138
|
+
manager.checkAndReinitGlassesMic()
|
|
139
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
140
|
+
now = 5
|
|
141
|
+
manager.checkAndReinitGlassesMic()
|
|
142
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
143
|
+
now = 5.1
|
|
144
|
+
manager.checkAndReinitGlassesMic()
|
|
145
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
146
|
+
now = 10
|
|
147
|
+
manager.checkAndReinitGlassesMic()
|
|
148
|
+
XCTAssertEqual(device.micChanges, [true, true, true])
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func testDecodedGlassesAudioKeepsWatchdogHealthyAndMissingPacketsAfterwardRetry() {
|
|
153
|
+
withRecordingDevice { manager, store in
|
|
154
|
+
var now: TimeInterval = 0
|
|
155
|
+
manager.micWatchdogNow = { now }
|
|
156
|
+
store.apply("glasses", "fullyBooted", true)
|
|
157
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
158
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
159
|
+
// This is the decoded-audio entry point used by Nimo and AR99.
|
|
160
|
+
for tick in 1 ... 10 {
|
|
161
|
+
now = TimeInterval(tick * 10)
|
|
162
|
+
manager.handleGlassesPcm(Data([0, 0]))
|
|
163
|
+
manager.checkAndReinitGlassesMic()
|
|
164
|
+
}
|
|
165
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
166
|
+
now = 104
|
|
167
|
+
manager.checkAndReinitGlassesMic()
|
|
168
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
169
|
+
now = 105
|
|
170
|
+
manager.checkAndReinitGlassesMic()
|
|
171
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func testLc3GlassesAudioKeepsWatchdogHealthy() {
|
|
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
|
+
let lc3 = manager.lc3Converter!.encode(Data(repeating: 0, count: 320), frameSize: 40) as Data
|
|
183
|
+
XCTAssertEqual(lc3.count, 40)
|
|
184
|
+
// G2 and Live deliver LC3 through this entry point, not decoded PCM.
|
|
185
|
+
for tick in 1 ... 10 {
|
|
186
|
+
now = TimeInterval(tick * 10)
|
|
187
|
+
manager.handleGlassesMicData(lc3, 40)
|
|
188
|
+
manager.checkAndReinitGlassesMic()
|
|
189
|
+
}
|
|
190
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func testPhonePcmAndEmptyGlassesPcmCannotHideAMissingGlassesStream() {
|
|
195
|
+
withRecordingDevice { manager, store in
|
|
196
|
+
var now: TimeInterval = 0
|
|
197
|
+
manager.micWatchdogNow = { now }
|
|
198
|
+
store.apply("glasses", "fullyBooted", true)
|
|
199
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
200
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
201
|
+
now = 5
|
|
202
|
+
manager.handlePcm(Data([0, 0]))
|
|
203
|
+
manager.handleGlassesPcm(Data())
|
|
204
|
+
manager.checkAndReinitGlassesMic()
|
|
205
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func testWatchdogRespectsSuspensionAndGivesResumeAFreshDeadline() {
|
|
210
|
+
withRecordingDevice { manager, store in
|
|
211
|
+
var now: TimeInterval = 0
|
|
212
|
+
manager.micWatchdogNow = { now }
|
|
213
|
+
store.apply("glasses", "fullyBooted", true)
|
|
214
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
215
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
216
|
+
device.isMicSuspendedForAudio = true
|
|
217
|
+
now = 100
|
|
218
|
+
manager.checkAndReinitGlassesMic()
|
|
219
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
220
|
+
device.isMicSuspendedForAudio = false
|
|
221
|
+
now = 200
|
|
222
|
+
manager.checkAndReinitGlassesMic()
|
|
223
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
224
|
+
now = 205
|
|
225
|
+
manager.checkAndReinitGlassesMic()
|
|
226
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
227
|
+
|
|
228
|
+
store.apply("bluetooth", "should_send_pcm", false)
|
|
229
|
+
// A late firmware ACK can leave the shared cache true after demand ends.
|
|
230
|
+
store.set("glasses", "micEnabled", true)
|
|
231
|
+
now = 300
|
|
232
|
+
manager.checkAndReinitGlassesMic()
|
|
233
|
+
XCTAssertEqual(device.micChanges, [true, true, false])
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func testWatchdogRespectsOwnAppPlaybackAndPhoneRoute() {
|
|
238
|
+
withRecordingDevice { manager, store in
|
|
239
|
+
var now: TimeInterval = 0
|
|
240
|
+
manager.micWatchdogNow = { now }
|
|
241
|
+
let monitor = PhoneAudioMonitor.getInstance()
|
|
242
|
+
let wasPlaying = monitor.isOwnAppAudioPlaying()
|
|
243
|
+
defer { monitor.setOwnAppAudioPlaying(wasPlaying) }
|
|
244
|
+
monitor.setOwnAppAudioPlaying(false)
|
|
245
|
+
store.apply("glasses", "fullyBooted", true)
|
|
246
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
247
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
248
|
+
monitor.setOwnAppAudioPlaying(true)
|
|
249
|
+
now = 100
|
|
250
|
+
manager.checkAndReinitGlassesMic()
|
|
251
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
252
|
+
monitor.setOwnAppAudioPlaying(false)
|
|
253
|
+
store.set("bluetooth", "currentMic", MicTypes.PHONE_INTERNAL)
|
|
254
|
+
now = 200
|
|
255
|
+
manager.checkAndReinitGlassesMic()
|
|
256
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
func testWatchdogDeadlineBelongsToTheCurrentConnection() {
|
|
261
|
+
withRecordingDevice { manager, store in
|
|
262
|
+
var now: TimeInterval = 0
|
|
263
|
+
manager.micWatchdogNow = { now }
|
|
264
|
+
store.apply("glasses", "fullyBooted", true)
|
|
265
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
266
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
267
|
+
store.apply("glasses", "fullyBooted", false)
|
|
268
|
+
now = 100
|
|
269
|
+
manager.checkAndReinitGlassesMic()
|
|
270
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
271
|
+
store.apply("glasses", "fullyBooted", true)
|
|
272
|
+
manager.checkAndReinitGlassesMic()
|
|
273
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
274
|
+
now = 105
|
|
275
|
+
manager.checkAndReinitGlassesMic()
|
|
276
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
@MainActor
|
|
282
|
+
private final class ReconnectRecordingDevice: SGCManager {
|
|
283
|
+
var type = "Reconnect test glasses"
|
|
284
|
+
let hasMic = true
|
|
285
|
+
let showConnectionConfirmation = false
|
|
286
|
+
var micChanges: [Bool] = []
|
|
287
|
+
var positions: [(Int, Int)] = []
|
|
288
|
+
var onPosition: (() -> Void)?
|
|
289
|
+
var isMicSuspendedForAudio = false
|
|
290
|
+
|
|
291
|
+
func clearSceneElements(_: [String]) async {}
|
|
292
|
+
func sendTextWall(_: String) async {}
|
|
293
|
+
func applySceneFrame(_: SceneFrame) async {}
|
|
294
|
+
func clearDisplay() {}
|
|
295
|
+
|
|
296
|
+
func setMicEnabled(_ enabled: Bool) {
|
|
297
|
+
micChanges.append(enabled)
|
|
298
|
+
DeviceStore.shared.set("glasses", "micEnabled", enabled)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func sortMicRanking(list: [String]) -> [String] {
|
|
302
|
+
list
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {}
|
|
306
|
+
func requestPhoto(_: PhotoRequest) {}
|
|
307
|
+
func startStream(_: [String: Any]) {}
|
|
308
|
+
func stopStream() {}
|
|
309
|
+
func sendStreamKeepAlive(_: [String: Any]) {}
|
|
310
|
+
func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) {}
|
|
311
|
+
func stopVideoRecording(requestId _: String) {}
|
|
312
|
+
func sendButtonPhotoSettings() {}
|
|
313
|
+
func sendButtonVideoRecordingSettings() {}
|
|
314
|
+
func sendCameraFovSetting() {}
|
|
315
|
+
func sendButtonMaxRecordingTime() {}
|
|
316
|
+
func setBrightness(_: Int, autoMode _: Bool) {}
|
|
317
|
+
func sendText(_: String) async {}
|
|
318
|
+
func sendDoubleTextWall(_: String, _: String) async {}
|
|
319
|
+
func displayBitmap(base64ImageData _: String, x _: Int32?, y _: Int32?, width _: Int32?, height _: Int32?) async -> Bool {
|
|
320
|
+
false
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
func showDashboard() {}
|
|
324
|
+
func setDashboardPosition(_ height: Int, _ depth: Int) {
|
|
325
|
+
positions.append((height, depth))
|
|
326
|
+
onPosition?()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
func setHeadUpAngle(_: Int) {}
|
|
330
|
+
func getBatteryStatus() {}
|
|
331
|
+
func setSilentMode(_: Bool) {}
|
|
332
|
+
func exit() {}
|
|
333
|
+
func sendShutdown() {}
|
|
334
|
+
func sendReboot() {}
|
|
335
|
+
func sendRgbLedControl(requestId _: String, packageName _: String?, action _: String, color _: String?, onDurationMs _: Int, offDurationMs _: Int, count _: Int) {}
|
|
336
|
+
func disconnect() {}
|
|
337
|
+
func forget() {}
|
|
338
|
+
func findCompatibleDevices() {}
|
|
339
|
+
func stopScan() {}
|
|
340
|
+
func connectById(_: String) {}
|
|
341
|
+
func getConnectedBluetoothName() -> String? {
|
|
342
|
+
nil
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func cleanup() {}
|
|
346
|
+
func ping() {}
|
|
347
|
+
func dbg1() {}
|
|
348
|
+
func dbg2() {}
|
|
349
|
+
func connectController() {}
|
|
350
|
+
func disconnectController() {}
|
|
351
|
+
func requestWifiScan(scanId _: String?) {}
|
|
352
|
+
func sendWifiCredentials(_: String, _: String) {}
|
|
353
|
+
func forgetWifiNetwork(_: String) {}
|
|
354
|
+
func sendHotspotState(_: Bool) {}
|
|
355
|
+
func sendUserEmailToGlasses(_: String) {}
|
|
356
|
+
func sendOtaStart(otaVersionUrl _: String?) {}
|
|
357
|
+
func sendOtaQueryStatus() {}
|
|
358
|
+
func queryGalleryStatus() {}
|
|
359
|
+
func sendGalleryMode() {}
|
|
360
|
+
func requestVersionInfo() {}
|
|
361
|
+
func sendIncidentId(_: String, apiBaseUrl _: String?) {}
|
|
362
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import CoreBluetooth
|
|
2
|
+
@testable import MentraBluetoothSDK
|
|
3
|
+
import XCTest
|
|
4
|
+
|
|
5
|
+
@MainActor
|
|
6
|
+
final class G1PairingTests: XCTestCase {
|
|
7
|
+
/// Exercise the real discovery/readiness entry points without accessing a
|
|
8
|
+
/// radio. Battery requests are observed through the SDK diagnostics.
|
|
9
|
+
private class RecordingG1: G1 {
|
|
10
|
+
var scans = 0
|
|
11
|
+
override func startScan() -> Bool {
|
|
12
|
+
scans += 1
|
|
13
|
+
return false // Central manager is still initializing.
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private func withGlasses(_ test: (RecordingG1) async throws -> Void) async rethrows {
|
|
18
|
+
let store = DeviceStore.shared.store
|
|
19
|
+
let saved = store.getCategory("glasses")
|
|
20
|
+
let keys = ["leftGlassUUID", "rightGlassUUID", "leftGlassSearchID", "rightGlassSearchID"]
|
|
21
|
+
let savedDefaults = keys.map { ($0, UserDefaults.standard.object(forKey: $0)) }
|
|
22
|
+
let glasses = RecordingG1()
|
|
23
|
+
defer {
|
|
24
|
+
glasses.forget()
|
|
25
|
+
for (key, value) in savedDefaults {
|
|
26
|
+
UserDefaults.standard.set(value, forKey: key)
|
|
27
|
+
}
|
|
28
|
+
for key in store.getCategory("glasses").keys where saved[key] == nil {
|
|
29
|
+
store.remove("glasses", key)
|
|
30
|
+
}
|
|
31
|
+
for (key, value) in saved {
|
|
32
|
+
store.set("glasses", key, value)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try await test(glasses)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func testFirstDiscoveryResumesWhenBluetoothPowersOn() async {
|
|
39
|
+
await withGlasses { glasses in
|
|
40
|
+
glasses.findCompatibleDevices()
|
|
41
|
+
XCTAssertEqual(glasses.DEVICE_SEARCH_ID, "NOT_SET")
|
|
42
|
+
XCTAssertEqual(glasses.scans, 1)
|
|
43
|
+
glasses.handleBluetoothState(.poweredOn)
|
|
44
|
+
XCTAssertEqual(glasses.scans, 2)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func testStoppedDiscoveryDoesNotResumeOnLatePowerCallback() async {
|
|
49
|
+
await withGlasses { glasses in
|
|
50
|
+
glasses.findCompatibleDevices()
|
|
51
|
+
glasses.stopScan()
|
|
52
|
+
glasses.handleBluetoothState(.poweredOn)
|
|
53
|
+
XCTAssertEqual(glasses.scans, 1)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func testSelectedPairResumesButCancelledPairDoesNot() async {
|
|
58
|
+
await withGlasses { glasses in
|
|
59
|
+
glasses.connectById("59")
|
|
60
|
+
glasses.handleBluetoothState(.poweredOn)
|
|
61
|
+
XCTAssertEqual(glasses.scans, 2)
|
|
62
|
+
XCTAssertEqual(glasses.DEVICE_SEARCH_ID, "_59_")
|
|
63
|
+
// Finding both arms stops scanning, but should not prevent a
|
|
64
|
+
// selected pair from reconnecting after Bluetooth is toggled.
|
|
65
|
+
glasses.stopScan()
|
|
66
|
+
glasses.handleBluetoothState(.poweredOff)
|
|
67
|
+
glasses.handleBluetoothState(.poweredOn)
|
|
68
|
+
XCTAssertEqual(glasses.scans, 3)
|
|
69
|
+
glasses.disconnect()
|
|
70
|
+
glasses.handleBluetoothState(.poweredOn)
|
|
71
|
+
XCTAssertEqual(glasses.scans, 3)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func testBatteryWaitsForBothArmsAndRefreshesAfterReconnect() async {
|
|
76
|
+
await withGlasses { glasses in
|
|
77
|
+
var batteryRequests = 0
|
|
78
|
+
let sink = Bridge.addEventSink { event, body in
|
|
79
|
+
if event == "log", body["message"] as? String == "G1: getBatteryStatus()" {
|
|
80
|
+
batteryRequests += 1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
defer { Bridge.removeEventSink(sink) }
|
|
84
|
+
glasses.getBatteryStatus()
|
|
85
|
+
XCTAssertEqual(batteryRequests, 0)
|
|
86
|
+
glasses.setReadiness(left: true, right: nil)
|
|
87
|
+
glasses.getBatteryStatus()
|
|
88
|
+
XCTAssertEqual(batteryRequests, 0)
|
|
89
|
+
glasses.setReadiness(left: nil, right: true)
|
|
90
|
+
XCTAssertEqual(batteryRequests, 1)
|
|
91
|
+
glasses.setReadiness(left: true, right: true)
|
|
92
|
+
XCTAssertEqual(batteryRequests, 1, "Duplicate init ACKs must not poll again")
|
|
93
|
+
glasses.setReadiness(left: false, right: false)
|
|
94
|
+
glasses.getBatteryStatus()
|
|
95
|
+
XCTAssertEqual(batteryRequests, 1)
|
|
96
|
+
glasses.setReadiness(left: nil, right: true)
|
|
97
|
+
glasses.setReadiness(left: true, right: nil)
|
|
98
|
+
XCTAssertEqual(batteryRequests, 2)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func testQueuedCommandWithoutTransportDoesNotReconnect() async throws {
|
|
103
|
+
try await withGlasses { glasses in
|
|
104
|
+
glasses.queueChunks([[Commands.BLE_REQ_BATTERY.rawValue, 0x01]])
|
|
105
|
+
// The original missing-characteristic path restarted scanning after
|
|
106
|
+
// five 100ms command attempts, before either arm could connect.
|
|
107
|
+
try await Task.sleep(nanoseconds: 1_000_000_000)
|
|
108
|
+
XCTAssertEqual(glasses.scans, 0)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
final class G1ReconnectionTests: XCTestCase {
|
|
114
|
+
func testSuccessfulReconnectAllowsALaterRetryLoop() async {
|
|
115
|
+
let manager = ReconnectionManager()
|
|
116
|
+
let connected = expectation(description: "Reconnect completes")
|
|
117
|
+
await manager.start {
|
|
118
|
+
connected.fulfill()
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
await fulfillment(of: [connected], timeout: 1)
|
|
122
|
+
let running = await manager.isRunning
|
|
123
|
+
XCTAssertFalse(running)
|
|
124
|
+
|
|
125
|
+
let retry = expectation(description: "A later disconnection can reconnect")
|
|
126
|
+
await manager.start {
|
|
127
|
+
retry.fulfill()
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
await fulfillment(of: [retry], timeout: 1)
|
|
131
|
+
await manager.stop()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func testRepeatedFailuresDoNotRestartTheRetryDelay() async {
|
|
135
|
+
let manager = ReconnectionManager(intervalSeconds: 60)
|
|
136
|
+
let firstAttempt = expectation(description: "First reconnect attempt")
|
|
137
|
+
await manager.start {
|
|
138
|
+
firstAttempt.fulfill()
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
await fulfillment(of: [firstAttempt], timeout: 1)
|
|
142
|
+
|
|
143
|
+
let restarted = expectation(description: "No immediate retry for another failed command")
|
|
144
|
+
restarted.isInverted = true
|
|
145
|
+
await manager.start {
|
|
146
|
+
restarted.fulfill()
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
await fulfillment(of: [restarted], timeout: 0.1)
|
|
150
|
+
let attempts = await manager.attemptCount
|
|
151
|
+
XCTAssertEqual(attempts, 1)
|
|
152
|
+
await manager.stop()
|
|
153
|
+
|
|
154
|
+
let nextAttempt = expectation(description: "A later connection can retry")
|
|
155
|
+
await manager.start {
|
|
156
|
+
nextAttempt.fulfill()
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
await fulfillment(of: [nextAttempt], timeout: 1)
|
|
160
|
+
await manager.stop()
|
|
161
|
+
}
|
|
162
|
+
}
|