@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
@@ -78,6 +78,39 @@ class MentraLiveGattCallbackTest {
78
78
  assertArrayEquals(byteArrayOf(3, 2), callback.packets[1])
79
79
  }
80
80
 
81
+ @Test
82
+ @Config(sdk = [33])
83
+ @Suppress("DEPRECATION")
84
+ fun `modern and legacy callbacks for one notification deliver one packet`() {
85
+ val queue = ArrayDeque<() -> Unit>()
86
+ val callback = RecordingCallback({ work -> queue.addLast(work) })
87
+ val gatt = gatt()
88
+ val characteristic = BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0)
89
+ val value = byteArrayOf(0xf1.toByte(), 7, 1, 2)
90
+ characteristic.value = value
91
+ callback.onCharacteristicChanged(gatt, characteristic, value)
92
+ callback.onCharacteristicChanged(gatt, characteristic)
93
+ value[2] = 9
94
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
95
+ assertEquals(1, callback.packets.size)
96
+ assertArrayEquals(byteArrayOf(0xf1.toByte(), 7, 1, 2), callback.packets.single())
97
+ }
98
+
99
+ @Test
100
+ @Config(sdk = [33])
101
+ fun `separate modern notifications retain identical payloads`() {
102
+ val queue = ArrayDeque<() -> Unit>()
103
+ val callback = RecordingCallback({ work -> queue.addLast(work) })
104
+ val gatt = gatt()
105
+ val characteristic = BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0)
106
+ val value = byteArrayOf(1, 2)
107
+ repeat(2) { callback.onCharacteristicChanged(gatt, characteristic, value) }
108
+ value[0] = 9
109
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
110
+ assertEquals(2, callback.packets.size)
111
+ callback.packets.forEach { assertArrayEquals(byteArrayOf(1, 2), it) }
112
+ }
113
+
81
114
  @Test
82
115
  fun `replacement invalidates callbacks already waiting on the lifecycle queue`() {
83
116
  val queue = ArrayDeque<() -> Unit>()
@@ -51,6 +51,46 @@ class NimoCanvasCoordinatorTest {
51
51
  fun finishUpdate(frame: ByteArray) { repeat(NimoCanvasCodec.frames(4, frame, 20).size) { written() }; ack(4) }
52
52
  }
53
53
 
54
+ @Test fun notReadyRetriesLaunchPromptlyButStopsAfterThreeProbes() {
55
+ val f = Fixture(); f.canvas.activate(); f.canvas.readiness(true)
56
+ repeat(4) { attempt ->
57
+ f.written(); f.ack(1, 7)
58
+ val before = f.writes.size
59
+ f.clock.advance(999)
60
+ assertEquals(before, f.writes.size)
61
+ f.clock.advance(1)
62
+ assertEquals(before + if (attempt < 3) 1 else 0, f.writes.size)
63
+ }
64
+ f.clock.advance(60_000)
65
+ assertEquals(4, f.writes.size)
66
+ assertTrue(f.failures.isEmpty())
67
+ }
68
+
69
+ @Test fun pendingReadinessRetryIsCancelledOnDisconnectExitAndKnownReadinessLoss() {
70
+ for (stop in listOf("disconnect", "exit", "notReady")) {
71
+ val f = Fixture(); f.canvas.activate(); f.canvas.readiness(true)
72
+ f.written(); f.ack(1, 7)
73
+ when (stop) {
74
+ "disconnect" -> f.canvas.disconnected()
75
+ "exit" -> f.canvas.exit()
76
+ else -> f.canvas.readiness(false)
77
+ }
78
+ f.clock.advance(2_000)
79
+ assertEquals(1, f.writes.size)
80
+ assertTrue(f.failures.isEmpty())
81
+ }
82
+ }
83
+
84
+ @Test fun readinessHeartbeatCancelsPendingProbeAndDoesNotDuplicateLaunch() {
85
+ val f = Fixture(); f.canvas.activate(); f.canvas.readiness(true)
86
+ f.written(); f.ack(1, 7)
87
+ f.canvas.confirmedReadiness(true)
88
+ f.clock.advance(1_000)
89
+ assertEquals(2, f.writes.size)
90
+ f.written(); f.ack(1); f.finishUpdate(byteArrayOf(0, 0, 1))
91
+ assertTrue(f.failures.isEmpty())
92
+ }
93
+
54
94
  @Test fun bothNotificationDescriptorsGateCharacteristicWrites() {
55
95
  val f = Fixture()
56
96
  val rx = Any(); val mic = Any()
@@ -184,7 +224,7 @@ class NimoCanvasCoordinatorTest {
184
224
  val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
185
225
  f.canvas.exit()
186
226
  f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
187
- assertFalse(f.canvas.nativeApp(0xFD, false))
227
+ f.canvas.nativeApp(0xFD, false)
188
228
  f.written()
189
229
  val before = f.writes.size
190
230
  f.ack(3)
@@ -192,15 +232,15 @@ class NimoCanvasCoordinatorTest {
192
232
  assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.last())
193
233
  }
194
234
 
195
- @Test fun actualNativeTakeoverDuringExitInvalidatesEncodingAndDoesNotRelaunch() {
235
+ @Test fun nativeTakeoverDuringExitRetainsNewerSceneUntilExitAck() {
196
236
  val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
197
237
  f.canvas.exit()
198
238
  f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
199
- assertTrue(f.canvas.nativeApp(0, true))
239
+ f.canvas.nativeApp(0, true)
200
240
  f.written()
201
241
  val before = f.writes.size
202
242
  f.ack(3)
203
- assertEquals(before, f.writes.size)
243
+ assertEquals(before + 1, f.writes.size)
204
244
  assertTrue(f.failures.isEmpty())
205
245
  }
206
246
 
@@ -210,31 +210,100 @@ class NimoCanvasSessionTest {
210
210
  assertArrayEquals(frameB, send(success(session, 1)).frame)
211
211
  }
212
212
 
213
- @Test fun actualNativeTakeoverDuringExitDiscardsNewerScene() {
213
+ @Test fun nativeReportDuringExitPreservesNewerHostScene() {
214
214
  val session = NimoCanvasSession()
215
215
  launched(session); success(session, 4)
216
216
  assertEquals(3, send(session.exit()).key)
217
217
  session.offer(frameB, "two:1")
218
218
  assertTrue(session.nativeApp(0, true).isEmpty())
219
- assertTrue(success(session, 3).isEmpty())
219
+ assertEquals(1, send(success(session, 3)).key)
220
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
221
+ }
222
+
223
+ @Test fun nativeTakeoverRestoresLatestSceneWithoutAnotherHostOffer() {
224
+ for (app in listOf(0, 0xFD)) {
225
+ val session = NimoCanvasSession()
226
+ launched(session); success(session, 4)
227
+ assertEquals(1, send(session.nativeApp(app, app != 0xFD)).key)
228
+ assertArrayEquals(frameA, send(success(session, 1)).frame)
229
+ }
230
+ }
231
+
232
+ @Test fun nativeTakeoverWaitsForCurrentAckInsteadOfOverlappingCommands() {
233
+ val session = NimoCanvasSession(); launched(session)
234
+ assertTrue(session.nativeApp(0, true).isEmpty())
235
+ session.offer(frameB, "one:1")
236
+ assertEquals(1, send(success(session, 4)).key)
237
+ assertTrue(session.nativeApp(0, true).isEmpty()) // Startup report during Launch.
238
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
239
+ }
240
+
241
+ @Test fun takeoverDuringUpdateReplaysIdenticalFrameAfterRelaunch() {
242
+ val session = NimoCanvasSession(); launched(session)
243
+ assertTrue(session.nativeApp(0, true).isEmpty())
244
+ // The old Update ACK must not deduplicate the same scene on a new canvas.
245
+ assertEquals(1, send(success(session, 4)).key)
246
+ assertArrayEquals(frameA, send(success(session, 1)).frame)
247
+ assertTrue(success(session, 4).isEmpty())
248
+ assertTrue(session.offer(frameA, "one:1").isEmpty())
249
+ }
250
+
251
+ @Test fun startupStockUiDoesNotDiscardRetainedSceneBeforeReadiness() {
252
+ val session = NimoCanvasSession(); launched(session); success(session, 4)
220
253
  session.disconnected()
221
- assertTrue(session.readiness(true).isEmpty())
254
+ assertTrue(session.nativeApp(0, true).isEmpty())
255
+ session.activate()
256
+ assertEquals(1, send(session.readiness(true)).key)
257
+ assertArrayEquals(frameA, send(success(session, 1)).frame)
222
258
  }
223
259
 
224
- @Test fun nativeTakeoverSuppressesReplayUntilNewHostFrame() {
225
- val session = NimoCanvasSession()
226
- launched(session); success(session, 4)
260
+ @Test fun explicitExitStillReturnsControlToStockUiUntilNextConnection() {
261
+ val session = NimoCanvasSession(); launched(session); success(session, 4)
262
+ assertEquals(3, send(session.exit()).key)
227
263
  assertTrue(session.nativeApp(0, true).isEmpty())
228
- assertTrue(session.readiness(true).isEmpty())
229
- assertEquals(1, send(session.offer(frameA, "one:1")).key)
264
+ assertTrue(success(session, 3).isEmpty())
265
+ assertTrue(session.nativeApp(0, true).isEmpty())
266
+ session.disconnected(); session.activate()
267
+ assertEquals(1, send(session.readiness(true)).key)
268
+ assertArrayEquals(byteArrayOf(0, 0, 1), send(success(session, 1)).frame)
230
269
  }
231
270
 
232
- @Test fun nativeTakeoverOrReadinessLossDuringCommandResets() {
233
- for (takeover in listOf(true, false)) {
234
- val session = NimoCanvasSession(); launched(session)
235
- val actions = if (takeover) session.nativeApp(0, true) else session.readiness(false)
236
- assertTrue(actions.single() is NimoCanvasSession.Action.Reconnect)
271
+ @Test fun activationLaunchesBlankCanvasWithoutAnyMiniapp() {
272
+ val session = NimoCanvasSession()
273
+ assertTrue(session.activate().isEmpty())
274
+ assertEquals(1, send(session.readiness(true)).key)
275
+ assertArrayEquals(byteArrayOf(0, 0, 1), send(success(session, 1)).frame)
276
+ }
277
+
278
+ @Test fun successfulUpdateRestoresProbeBudgetForNextRecovery() {
279
+ val session = NimoCanvasSession(); session.activate(); session.readiness(true)
280
+ repeat(2) { episode ->
281
+ if (episode > 0) assertEquals(1, send(session.nativeApp(0, true)).key)
282
+ repeat(3) {
283
+ session.response(1, byteArrayOf(7))
284
+ assertEquals(1, send(session.retryNotReady()).key)
285
+ }
286
+ assertEquals(4, send(success(session, 1)).key)
287
+ assertTrue(success(session, 4).isEmpty())
288
+ }
289
+ }
290
+
291
+ @Test fun notReadyProbesAreBoundedAndFreshHeartbeatStillRecovers() {
292
+ val session = NimoCanvasSession(); session.activate(); session.readiness(true)
293
+ repeat(3) {
294
+ session.response(1, byteArrayOf(7))
295
+ assertEquals(1, send(session.retryNotReady()).key)
237
296
  }
297
+ session.response(1, byteArrayOf(7))
298
+ repeat(5) { assertTrue(session.retryNotReady().isEmpty()) }
299
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
300
+ assertEquals(4, send(success(session, 1)).key)
301
+ }
302
+
303
+ @Test fun lostReadinessDuringCommandStillResets() {
304
+ val session = NimoCanvasSession(); launched(session)
305
+ assertTrue(session.readiness(false).single() is NimoCanvasSession.Action.Reconnect)
306
+ assertTrue(session.retryNotReady().isEmpty())
238
307
  }
239
308
 
240
309
  @Test fun offersCopyMutableInputAndRejectOversizedFrame() {
@@ -0,0 +1,93 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothGattCharacteristic
4
+ import androidx.test.core.app.ApplicationProvider
5
+ import com.mentra.bluetoothsdk.Bridge
6
+ import com.mentra.bluetoothsdk.DeviceManager
7
+ import com.mentra.bluetoothsdk.DeviceStore
8
+ import org.junit.Assert.*
9
+ import org.junit.Test
10
+ import org.junit.runner.RunWith
11
+ import org.robolectric.RobolectricTestRunner
12
+ import org.robolectric.annotation.Config
13
+ import org.robolectric.annotation.LooperMode
14
+ import java.util.UUID
15
+
16
+ @RunWith(RobolectricTestRunner::class)
17
+ @Config(sdk = [33])
18
+ @LooperMode(LooperMode.Mode.PAUSED)
19
+ class NimoMicReconnectTest {
20
+ private val requests = listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")
21
+
22
+ @Test fun sameAdapterReconnectRestartsTheStoppedAudioClientForRetainedConsumers() {
23
+ for (request in requests) withNimo { manager, device ->
24
+ DeviceStore.apply("bluetooth", request, true)
25
+ assertTrue(audioStarted(device))
26
+ repeat(3) {
27
+ resetLink(device)
28
+ assertFalse(audioStarted(device))
29
+ assertEquals(true, DeviceStore.get("bluetooth", request))
30
+ // This is the same adapter, not the replacement fake used by the manager tests.
31
+ DeviceStore.apply("glasses", "fullyBooted", true)
32
+ assertSame(device, manager.sgc)
33
+ assertTrue("Readiness must restart Nimo decoding without waiting for the watchdog", audioStarted(device))
34
+ }
35
+ }
36
+ }
37
+
38
+ @Test fun consumerStoppingDuringLinkLossDoesNotRestartAudio() = withNimo { _, device ->
39
+ DeviceStore.apply("bluetooth", "should_send_pcm", true)
40
+ assertTrue(audioStarted(device))
41
+ resetLink(device)
42
+ DeviceStore.apply("bluetooth", "should_send_pcm", false)
43
+ DeviceStore.apply("glasses", "fullyBooted", true)
44
+ assertFalse(audioStarted(device))
45
+ assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
46
+ }
47
+
48
+ private fun resetLink(device: Nimo) {
49
+ // Exercise the production reset called by the GATT disconnect callback.
50
+ Nimo::class.java.getDeclaredMethod("resetSessionState").apply { isAccessible = true }.invoke(device)
51
+ DeviceStore.apply("glasses", "fullyBooted", false)
52
+ }
53
+
54
+ private fun audioStarted(device: Nimo): Boolean {
55
+ val audio = Nimo::class.java.getDeclaredField("audioClient").apply { isAccessible = true }.get(device)
56
+ return audio.javaClass.getDeclaredField("started").apply { isAccessible = true }.getBoolean(audio)
57
+ }
58
+
59
+ private fun withNimo(block: (DeviceManager, Nimo) -> Unit) {
60
+ Bridge.initialize(ApplicationProvider.getApplicationContext())
61
+ val singleton = DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
62
+ val previous = singleton.get(null)
63
+ val saved = listOf("bluetooth", "glasses").associateWith { DeviceStore.store.getCategory(it) }
64
+ val manager = DeviceManager(initializeHardware = false)
65
+ singleton.set(null, manager)
66
+ val device = Nimo()
67
+ manager.sgc = device
68
+ // No radio/firmware: supply the discovered mic characteristic and inspect the real
69
+ // audio-client lifecycle. Codec output and BLE delivery still require a device test.
70
+ Nimo::class.java.getDeclaredField("micChar").apply { isAccessible = true }.set(
71
+ device, BluetoothGattCharacteristic(UUID.randomUUID(), BluetoothGattCharacteristic.PROPERTY_WRITE, 0)
72
+ )
73
+ requests.forEach { DeviceStore.set("bluetooth", it, false) }
74
+ DeviceStore.set("bluetooth", "micEnabled", false)
75
+ DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
76
+ DeviceStore.set("glasses", "micEnabled", false)
77
+ DeviceStore.set("glasses", "fullyBooted", false)
78
+ try {
79
+ block(manager, device)
80
+ } finally {
81
+ device.cleanup()
82
+ manager.sgc = null
83
+ manager.cleanup()
84
+ singleton.set(null, previous)
85
+ saved.forEach { (category, values) ->
86
+ DeviceStore.store.getCategory(category).keys.filter { it !in values }.forEach {
87
+ DeviceStore.store.remove(category, it)
88
+ }
89
+ values.forEach { (key, value) -> DeviceStore.set(category, key, value) }
90
+ }
91
+ }
92
+ }
93
+ }
@@ -19,6 +19,17 @@ class NimoCrc16Test {
19
19
  }
20
20
 
21
21
  class NimoFrameCodecTest {
22
+ @Test
23
+ fun `system English and factory reset match firmware wire commands`() {
24
+ fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02X".format(it.toInt() and 255) }
25
+ assertThat(hex(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_SET_PARAMETER,
26
+ NimoProtocol.SET_SYSTEM_LANGUAGE, byteArrayOf(NimoProtocol.LANGUAGE_ENGLISH.toByte()))))
27
+ .isEqualTo("BF020500702500000324010001")
28
+ assertThat(hex(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_CONTROL_FACTORY,
29
+ NimoProtocol.FACTORY_RECOVER, byteArrayOf())))
30
+ .isEqualTo("BF0204005358000008030000")
31
+ }
32
+
22
33
 
23
34
  @Test
24
35
  fun `transport header layout`() {
@@ -1,5 +1,8 @@
1
1
  package com.mentra.bluetoothsdk.sgcs
2
2
 
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothGattCharacteristic
5
+ import org.robolectric.shadows.ShadowBluetoothGatt
3
6
  import android.content.BroadcastReceiver
4
7
  import android.content.Context
5
8
  import android.content.ContextWrapper
@@ -134,6 +137,97 @@ class NimoLifecycleTest {
134
137
  assertEquals(1, writes)
135
138
  }
136
139
 
140
+ @Test fun resetForUnpairOfflineDoesNotSendFactoryCommand() {
141
+ val results = mutableListOf<Boolean>()
142
+ nimo.resetForUnpair { results.add(it) }
143
+ assertEquals(listOf(false), results)
144
+ }
145
+
146
+ @Test fun explicitResetWaitsForMatchingAckAndCompletesOnlyOnce() {
147
+ val frames = readyForReset()
148
+ val results = mutableListOf<Boolean>()
149
+ nimo.resetForUnpair { results.add(it) }
150
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
151
+ assertTrue(results.isEmpty())
152
+ assertArrayEquals(byteArrayOf(8, 3, 0, 0), frames.single().drop(8).toByteArray())
153
+ assertEquals(2, frames.single()[1].toInt())
154
+ respond(3, 1, 0)
155
+ assertTrue(results.isEmpty())
156
+ respond(8, 3, 0)
157
+ respond(8, 3, 0)
158
+ assertEquals(listOf(true), results)
159
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(6, TimeUnit.SECONDS)
160
+ assertEquals(listOf(true), results)
161
+ }
162
+
163
+ @Test fun resetTimeoutCompletesRatherThanLeavingUnpairWaitingForever() {
164
+ readyForReset()
165
+ val results = mutableListOf<Boolean>()
166
+ nimo.resetForUnpair { results.add(it) }
167
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(6, TimeUnit.SECONDS)
168
+ assertEquals(listOf(false), results)
169
+ respond(8, 3, 0)
170
+ assertEquals(listOf(false), results)
171
+ }
172
+
173
+ @Test fun passiveForgetNeverSendsFactoryReset() {
174
+ val frames = readyForReset()
175
+ nimo.forget()
176
+ assertTrue(frames.isEmpty())
177
+ }
178
+
179
+ @Test fun unbondTargetsOnlyTheSelectedNimo() {
180
+ val adapter = BluetoothAdapter.getDefaultAdapter()
181
+ val selected = adapter.getRemoteDevice("00:11:22:33:44:55")
182
+ val other = adapter.getRemoteDevice("00:11:22:33:44:66")
183
+ Shadows.shadowOf(selected).setCreatedBond(true)
184
+ Shadows.shadowOf(other).setCreatedBond(true)
185
+ Nimo.removeBluetoothBond(selected.address)
186
+ // Robolectric tracks removeBond through this flag, independently of bondState.
187
+ assertFalse(selected.createBond())
188
+ assertTrue(other.createBond())
189
+ }
190
+
191
+ private fun readyForReset(): MutableList<ByteArray> {
192
+ val frames = mutableListOf<ByteArray>()
193
+ val device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("00:11:22:33:44:55")
194
+ val gatt = ShadowBluetoothGatt.newInstance(device)
195
+ setField("gatt", gatt)
196
+ setField("txChar", BluetoothGattCharacteristic(NimoBLE.CHAR_TX, 8, 16))
197
+ val state = Nimo::class.java.getDeclaredField("handshakeState")
198
+ setField("handshakeState", state.type.enumConstants.single { it.toString() == "READY" })
199
+ lateinit var queue: NimoGattQueue<BluetoothGattCharacteristic>
200
+ queue = NimoGattQueue(
201
+ NimoScheduler { delay, task ->
202
+ val handler = Handler(Looper.getMainLooper())
203
+ val pending = Runnable { task() }
204
+ handler.postDelayed(pending, delay)
205
+ val cancel: () -> Unit = { handler.removeCallbacks(pending) }
206
+ cancel
207
+ },
208
+ { characteristic, bytes ->
209
+ frames.add(bytes.copyOf())
210
+ Handler(Looper.getMainLooper()).post { queue.written(gatt, characteristic, true) }
211
+ true
212
+ },
213
+ { fail(it) },
214
+ NimoBLE.INTER_FRAME_DELAY_MS,
215
+ )
216
+ queue.connected(gatt)
217
+ setField("writes", queue)
218
+ return frames
219
+ }
220
+
221
+ private fun setField(name: String, value: Any) {
222
+ Nimo::class.java.getDeclaredField(name).apply { isAccessible = true }.set(nimo, value)
223
+ }
224
+
225
+ private fun respond(cmd: Int, key: Int, status: Int) {
226
+ Nimo::class.java.getDeclaredMethod("handleResponse", Int::class.javaPrimitiveType,
227
+ Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, ByteArray::class.java)
228
+ .apply { isAccessible = true }.invoke(nimo, cmd, key, status, byteArrayOf())
229
+ }
230
+
137
231
  private fun runOffMain(operation: () -> Unit) {
138
232
  val failure = AtomicReference<Throwable?>()
139
233
  val caller = Thread({
@@ -121,25 +121,31 @@ class NimoReadinessDriverTest {
121
121
  assertTrue(failures.isEmpty())
122
122
  }
123
123
 
124
- @Test fun unknownPeerCannotLaunchFromHandshakeOrTwsOnlyReports() {
125
- canvas.offer(byteArrayOf(0, 0, 1), "initial")
124
+ @Test fun freshHandshakeProbesCanvasWithoutWaitingForHeartbeat() {
126
125
  finishHandshake(peer = null)
127
- assertKeys()
128
- emit(packet(6, 3, byteArrayOf(0, 1)))
129
126
  assertNull(field("peerCompanionReady").get(nimo))
127
+ assertKeys(1) // Firmware validates peer readiness, not a synthetic heartbeat.
128
+ emit(packet(6, 2, byteArrayOf(0, 0, 1))) // Stock dashboard entering at startup.
129
+ assertKeys(1)
130
+ emit(packet(7, 1, byteArrayOf(0, 0xFD.toByte())))
131
+ assertKeys(1, 4)
132
+ assertArrayEquals(NimoCanvasCodec.frames(4, byteArrayOf(0, 0, 1), 512).single(), frames.last())
133
+ assertTrue(failures.isEmpty())
134
+ }
135
+
136
+ @Test fun knownDisconnectedPeerStillBlocksUntilReadinessArrives() {
137
+ finishHandshake(peer = false)
130
138
  assertKeys()
131
- emit(heartbeat(peer = 0))
132
- assertEquals(false, field("peerCompanionReady").get(nimo))
139
+ emit(packet(6, 3, byteArrayOf(0, 1)))
133
140
  assertKeys()
134
141
  emit(heartbeat())
135
- assertEquals(true, field("peerCompanionReady").get(nimo))
136
142
  assertKeys(1)
137
143
  assertTrue(failures.isEmpty())
138
144
  }
139
145
 
140
146
  @Test fun queuedOldGattAndWrongServiceCannotEstablishReadiness() {
141
147
  canvas.offer(byteArrayOf(0, 0, 1), "initial")
142
- finishHandshake(peer = null)
148
+ finishHandshake(peer = false)
143
149
  val oldGatt = currentGatt
144
150
  queue(heartbeat(), oldGatt)
145
151
  currentGatt = newGatt()
@@ -147,7 +153,7 @@ class NimoReadinessDriverTest {
147
153
  idle() // The generation check must occur when the queued callback executes.
148
154
  emit(heartbeat(), oldGatt)
149
155
  emit(heartbeat(), currentGatt, characteristic(UUID.fromString("00001234-0000-1000-8000-00805f9b34fb")))
150
- assertNull(field("peerCompanionReady").get(nimo))
156
+ assertEquals(false, field("peerCompanionReady").get(nimo))
151
157
  assertKeys()
152
158
 
153
159
  emit(heartbeat()) // Positive control through the actual current RX callback.
@@ -1341,6 +1341,8 @@ export interface OtaProgress {
1341
1341
  errorMessage?: string;
1342
1342
  }
1343
1343
  export interface GlassesStatus {
1344
+ /** iOS G2 arm still disconnected after a three-second partial link; cleared on reset or both links. */
1345
+ g2MissingArm?: "left" | "right" | null;
1344
1346
  connection: GlassesConnectionStatus;
1345
1347
  micEnabled: boolean;
1346
1348
  voiceActivityDetectionEnabled: boolean;