@mentra/bluetooth-sdk 3.2.1-dev.311 → 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.
Files changed (133) hide show
  1. package/README.md +4 -4
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. 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
+ }