@mentra/bluetooth-sdk 3.3.0-dev.517 → 3.3.0-dev.523

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 (50) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +55 -10
  3. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  4. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +63 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  8. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +111 -2
  9. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  10. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
  11. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
  12. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  14. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
  15. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
  16. package/build/BluetoothSdk.types.d.ts +2 -0
  17. package/build/BluetoothSdk.types.d.ts.map +1 -1
  18. package/build/BluetoothSdk.types.js +1 -0
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +4 -0
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/DeviceManager.swift +66 -50
  28. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  29. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  30. package/ios/Source/sgcs/Ar99.swift +1 -2
  31. package/ios/Source/sgcs/G2.swift +259 -245
  32. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  33. package/ios/Source/sgcs/Nimo.swift +104 -43
  34. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
  35. package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
  36. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  37. package/ios/Source/status/DeviceStatus.swift +1 -0
  38. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  39. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  40. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  41. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  42. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  43. package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
  44. package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
  45. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  46. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  47. package/package.json +2 -2
  48. package/src/BluetoothSdk.types.ts +3 -0
  49. package/src/_private/BluetoothSdkModule.ts +2 -0
  50. package/src/generated/releaseMetadata.ts +5 -5
@@ -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,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
+ }
@@ -102,11 +102,80 @@ final class NimoCanvasSessionTests: XCTestCase {
102
102
  XCTAssertEqual(frame, b)
103
103
  }
104
104
 
105
- func testNativeTakeoverSuppressesReplayUntilHostOffersAgain() {
105
+ func testNativeTakeoverReplaysWithoutNewHostFrame() {
106
106
  let session = NimoCanvasSession(); connected(session)
107
- XCTAssertTrue(session.nativeApp(1, entered: true).isEmpty)
108
- XCTAssertTrue(session.readiness(true, confirmed: true).isEmpty)
109
- XCTAssertEqual(sentKey(session.offer(b, scope: "new")), 1)
110
- XCTAssertEqual(session.nativeApp(1, entered: true), [.reconnect("Native app transition interrupted canvas command")])
107
+ XCTAssertEqual(sentKey(session.nativeApp(0, entered: true)), 1)
108
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
109
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
110
+ XCTAssertEqual(frame, a)
111
+ XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
112
+ XCTAssertEqual(sentKey(ack(session, 4)), 1)
113
+ }
114
+
115
+ func testTakeoverDuringUpdateReplaysIdenticalFrameAfterRelaunch() {
116
+ let session = NimoCanvasSession()
117
+ _ = session.offer(a, scope: "app:1"); _ = session.readiness(true)
118
+ XCTAssertEqual(sentKey(ack(session, 1)), 4)
119
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
120
+ XCTAssertEqual(sentKey(ack(session, 4)), 1)
121
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail("Missing replay Update") }
122
+ XCTAssertEqual(frame, a)
123
+ XCTAssertTrue(ack(session, 4).isEmpty)
124
+ XCTAssertTrue(session.offer(a, scope: "app:1").isEmpty)
125
+ }
126
+
127
+ func testStartupStockUiPreservesReconnectScene() {
128
+ let session = NimoCanvasSession(); connected(session)
129
+ session.disconnected()
130
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
131
+ XCTAssertTrue(session.activate().isEmpty)
132
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
133
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
134
+ XCTAssertEqual(frame, a)
135
+ }
136
+
137
+ func testActivationEntersBlankCanvasBeforeAnyMiniapp() {
138
+ let session = NimoCanvasSession()
139
+ XCTAssertTrue(session.activate().isEmpty)
140
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
141
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
142
+ XCTAssertEqual(frame, Data([0, 0, 1]))
143
+ }
144
+
145
+ func testExplicitExitStopsRestorationUntilAnotherConnection() {
146
+ let session = NimoCanvasSession(); connected(session)
147
+ XCTAssertEqual(sentKey(session.exit()), 3)
148
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
149
+ XCTAssertTrue(ack(session, 3).isEmpty)
150
+ XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
151
+ session.disconnected()
152
+ _ = session.activate()
153
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
154
+ }
155
+
156
+ func testSuccessfulUpdateRestoresProbeBudgetForNextRecovery() {
157
+ let session = NimoCanvasSession()
158
+ _ = session.activate(); _ = session.readiness(true)
159
+ for episode in 0 ..< 2 {
160
+ if episode > 0 { XCTAssertEqual(sentKey(session.nativeApp(0, entered: true)), 1) }
161
+ for _ in 0 ..< 3 {
162
+ _ = ack(session, 1, status: 7)
163
+ XCTAssertEqual(sentKey(session.retryNotReady()), 1)
164
+ }
165
+ XCTAssertEqual(sentKey(ack(session, 1)), 4)
166
+ XCTAssertTrue(ack(session, 4).isEmpty)
167
+ }
168
+ }
169
+
170
+ func testProbesAreBoundedAndHeartbeatCanStillRecover() {
171
+ let session = NimoCanvasSession()
172
+ _ = session.activate(); _ = session.readiness(true)
173
+ for _ in 0 ..< 3 {
174
+ _ = ack(session, 1, status: 7)
175
+ XCTAssertEqual(sentKey(session.retryNotReady()), 1)
176
+ }
177
+ _ = ack(session, 1, status: 7)
178
+ XCTAssertTrue(session.retryNotReady().isEmpty)
179
+ XCTAssertEqual(sentKey(session.readiness(true, confirmed: true)), 1)
111
180
  }
112
181
  }
@@ -33,6 +33,31 @@ private final class NimoTestClock {
33
33
  final class NimoCanvasTransportTests: XCTestCase {
34
34
  private let frame = Data([0, 0, 1])
35
35
 
36
+ func testNotReadyProbesAreTimedBoundedAndCancelled() {
37
+ for stop in ["budget", "disconnect", "exit", "notReady"] {
38
+ let clock = NimoTestClock()
39
+ var keys: [Int] = []
40
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { frames, start, complete in
41
+ keys.append(Int(frames[0][9])); start(); complete(); return true
42
+ }, reconnect: { XCTFail($0) }, rejected: { _ in })
43
+ canvas.activate(); canvas.readiness(true)
44
+ for attempt in 0 ..< 4 {
45
+ canvas.response(key: 1, payload: Data([7]))
46
+ if stop == "disconnect" { canvas.disconnected() }
47
+ if stop == "exit" { canvas.exit() }
48
+ if stop == "notReady" { canvas.readiness(false) }
49
+ let before = keys.count
50
+ clock.advance(0.999)
51
+ XCTAssertEqual(keys.count, before)
52
+ clock.advance(0.001)
53
+ XCTAssertEqual(keys.count, before + (stop == "budget" && attempt < 3 ? 1 : 0))
54
+ if stop != "budget" { break }
55
+ }
56
+ clock.advance(60)
57
+ XCTAssertEqual(keys.count, stop == "budget" ? 4 : 1)
58
+ }
59
+ }
60
+
36
61
  func testEarlyAckOnlyAdvancesAfterFinalWriteCompletes() {
37
62
  let clock = NimoTestClock()
38
63
  var chains: [[Data]] = [], starts: [() -> Void] = [], completes: [() -> Void] = []
@@ -0,0 +1,16 @@
1
+ import Foundation
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class NimoDeviceCommandsTests: XCTestCase {
6
+ func testSystemEnglishAndFactoryResetMatchFirmwareWireCommands() {
7
+ func hex(_ bytes: Data) -> String {
8
+ bytes.map { String(format: "%02X", $0) }.joined()
9
+ }
10
+ XCTAssertEqual(hex(NimoFrameCodec.encodeFrame(cmd: NimoProtocol.CMD_SET_PARAMETER,
11
+ key: NimoProtocol.SET_SYSTEM_LANGUAGE, payload: Data([NimoProtocol.LANGUAGE_ENGLISH]))),
12
+ "BF020500702500000324010001")
13
+ XCTAssertEqual(hex(NimoFrameCodec.encodeFrame(cmd: NimoProtocol.CMD_CONTROL_FACTORY,
14
+ key: NimoProtocol.FACTORY_RECOVER, payload: Data())), "BF0204005358000008030000")
15
+ }
16
+ }