@mentra/bluetooth-sdk 3.3.0-dev.517 → 3.3.0-dev.525
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +55 -10
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +63 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +111 -2
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
- 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/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
- package/build/BluetoothSdk.types.d.ts +2 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +1 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +4 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +66 -50
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/G2.swift +259 -245
- package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
- package/ios/Source/sgcs/Nimo.swift +104 -43
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
- package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
- package/ios/Source/sgcs/NimoDiscovery.swift +49 -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/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
- package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
- package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
- package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
- package/ios/Tests/NimoDiscoveryTests.swift +79 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +3 -0
- package/src/_private/BluetoothSdkModule.ts +2 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
225
|
-
val session = NimoCanvasSession()
|
|
226
|
-
|
|
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
|
|
229
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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;
|