@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,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`() {
@@ -0,0 +1,27 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class SelectedDeviceAddressTest {
8
+ @Test fun `selected pending address wins over saved address`() {
9
+ assertEquals("new", SelectedDeviceAddress.resolve("LIVE_2", "LIVE_2", "new", "LIVE_1", "old"))
10
+ }
11
+
12
+ @Test fun `a name-only selection cannot use another device's saved address`() {
13
+ assertNull(SelectedDeviceAddress.resolve("LIVE_2", "LIVE_2", "", "LIVE_1", "old"))
14
+ assertNull(SelectedDeviceAddress.resolve("Nex1-2", "Nex1-2", null, "Nex1-1", "old"))
15
+ assertNull(SelectedDeviceAddress.resolve("Nex1-2", null, null, "Simulated Glasses", ""))
16
+ }
17
+
18
+ @Test fun `saved reconnect keeps its address even with an unrelated pending selection`() {
19
+ assertEquals("old", SelectedDeviceAddress.resolve("LIVE_1", "LIVE_2", "new", "LIVE_1", "old"))
20
+ assertEquals("old", SelectedDeviceAddress.resolve("Nex1-1", null, null, "Nex1-1", "old"))
21
+ }
22
+
23
+ @Test fun `empty or mismatched identities never supply an address`() {
24
+ assertNull(SelectedDeviceAddress.resolve("", "", "new", "", "old"))
25
+ assertNull(SelectedDeviceAddress.resolve("LIVE_2", "LIVE_1", "new", "LIVE_1", "old"))
26
+ }
27
+ }
@@ -0,0 +1,287 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import androidx.test.core.app.ApplicationProvider
7
+ import java.io.File
8
+ import java.util.concurrent.TimeUnit
9
+ import org.json.JSONObject
10
+ import org.junit.After
11
+ import org.junit.Assert.*
12
+ import org.junit.Before
13
+ import org.junit.Test
14
+ import org.junit.runner.RunWith
15
+ import org.robolectric.RobolectricTestRunner
16
+ import org.robolectric.Shadows
17
+ import org.robolectric.annotation.Config
18
+ import org.robolectric.annotation.LooperMode
19
+
20
+ @RunWith(RobolectricTestRunner::class)
21
+ @Config(sdk = [33])
22
+ @LooperMode(LooperMode.Mode.PAUSED)
23
+ class NimoDiagnosticsTest {
24
+ private data class Write(val packet: ByteArray, val completed: () -> Unit)
25
+ private val writes = mutableListOf<Write>()
26
+ private var heldReady: (() -> Unit)? = null
27
+ private val holdReleases = mutableListOf<Boolean>()
28
+ private lateinit var diagnostics: NimoDiagnostics
29
+ private lateinit var root: File
30
+ private lateinit var previousDirectories: Set<String>
31
+
32
+ @Before fun setup() {
33
+ val context = ApplicationProvider.getApplicationContext<Context>()
34
+ root = File(context.filesDir, "nimo-framebuffer")
35
+ previousDirectories = root.listFiles()?.map { it.name }?.toSet() ?: emptySet()
36
+ diagnostics = NimoDiagnostics(
37
+ context,
38
+ Handler(Looper.getMainLooper()),
39
+ { frame, complete -> writes.add(Write(frame.copyOf(), complete)); true },
40
+ { ready -> heldReady = ready },
41
+ { resume -> holdReleases += resume },
42
+ )
43
+ diagnostics.connected(512)
44
+ }
45
+
46
+ @After fun dispose() { diagnostics.close() }
47
+
48
+ @Test fun probePreservesNotReadyAndAcceptsResponsesBeforeWriteCallbacks() {
49
+ start("probe")
50
+ driveToPageGate()
51
+ assertTrue(diagnostics.onPacket(fbpReply(writes[3].packet, ByteArray(240))))
52
+ val result = receipt()
53
+ assertTrue(result.getBoolean("probe_ok"))
54
+ assertFalse(result.getBoolean("ok"))
55
+ assertTrue(result.isNull("error"))
56
+ assertEquals(0, result.getJSONObject("readiness").getInt("ready"))
57
+ assertEquals(1, result.getJSONObject("readiness").getInt("tws"))
58
+ assertEquals(NimoFramebufferProtocol.EXPECTED_VERSION, result.getString("runtime_version"))
59
+ assertEquals(8, result.getInt("wire_events"))
60
+ assertEquals(8, File(captureDirectory(), "wire.jsonl").readLines().size)
61
+ writes.toList().forEach { it.completed() }
62
+ idle(4_000)
63
+ assertEquals(4, writes.size)
64
+ assertTrue(receipt().getBoolean("probe_ok"))
65
+ assertTrue(writes.all { it.packet[8] == 2.toByte() && (it.packet[9].toInt() and 255) in listOf(0x0B, 0x19, 0x15) })
66
+ }
67
+
68
+ @Test fun duplicateTriggerDoesNotStartAnotherRead() {
69
+ start("capture")
70
+ start("probe")
71
+ assertEquals(1, writes.size)
72
+ assertEquals(1, newDirectories().size)
73
+ }
74
+
75
+ @Test fun disconnectCancelsPendingTimeoutAndIgnoresLateWriteCompletion() {
76
+ start("capture")
77
+ writes.single().completed()
78
+ diagnostics.disconnected()
79
+ val error = receipt().getString("error")
80
+ assertTrue(error.contains("disconnected"))
81
+ diagnostics.connected(512)
82
+ writes.single().completed()
83
+ idle(4_000)
84
+ assertEquals(error, receipt().getString("error"))
85
+ assertEquals(1, writes.size)
86
+ assertFalse(diagnostics.onPacket(versionReply()))
87
+ assertFalse(receipt().getBoolean("ok"))
88
+ }
89
+
90
+ @Test fun timeoutStartsAtWriteCompletionAndDoesNotRetry() {
91
+ start("probe")
92
+ idle(5_000)
93
+ assertFalse(File(captureDirectory(), "session.json").exists())
94
+ writes.single().completed()
95
+ idle(2_900)
96
+ assertFalse(File(captureDirectory(), "session.json").exists())
97
+ idle(200)
98
+ assertTrue(receipt().getString("error").contains("response timeout"))
99
+ assertEquals(1, writes.size)
100
+ }
101
+
102
+ @Test fun captureRejectsCrcBeforeRequestingBulkPages() {
103
+ start("capture")
104
+ driveToPageGate()
105
+ val corrupted = fbpReply(writes[3].packet, ByteArray(240)).also { it[4] = (it[4].toInt() xor 1).toByte() }
106
+ assertTrue(diagnostics.onPacket(corrupted))
107
+ assertFalse(receipt().getBoolean("ok"))
108
+ assertFalse(receipt().getBoolean("probe_ok"))
109
+ assertTrue(receipt().getString("error").contains("CRC mismatch"))
110
+ assertEquals(4, writes.size)
111
+ assertEquals(0, receipt().getJSONArray("banks").length())
112
+ }
113
+
114
+ @Test fun totalDeadlineBoundsAWriteThatNeverCompletes() {
115
+ start("capture")
116
+ idle(600_001)
117
+ assertTrue(receipt().getString("error").contains("10-minute budget"))
118
+ writes.single().completed()
119
+ idle(4_000)
120
+ assertEquals(1, writes.size)
121
+ }
122
+
123
+ @Test fun heldSampleBudgetIncludesCanvasDrainAndStaleReadyCannotStartCapture() {
124
+ start("held-sample")
125
+ assertTrue(writes.isEmpty())
126
+ assertNotNull(heldReady)
127
+
128
+ idle(15_001)
129
+
130
+ assertEquals(listOf(true), holdReleases)
131
+ assertTrue(receipt().getString("error").contains("15-second budget"))
132
+ heldReady?.invoke()
133
+ assertTrue(writes.isEmpty())
134
+ assertEquals(listOf(true), holdReleases)
135
+ }
136
+
137
+ @Test fun disconnectBeforeHeldCaptureIsReadyReleasesWithoutResumingCanvas() {
138
+ start("held-capture")
139
+
140
+ diagnostics.disconnected()
141
+
142
+ assertEquals(listOf(false), holdReleases)
143
+ assertTrue(receipt().getString("error").contains("disconnected"))
144
+ heldReady?.invoke()
145
+ assertTrue(writes.isEmpty())
146
+ assertEquals(listOf(false), holdReleases)
147
+ }
148
+
149
+ @Test fun duplicateHeldCaptureDoesNotReplaceHoldOrDeadline() {
150
+ start("held-sample")
151
+ val firstReady = heldReady
152
+
153
+ start("held-capture")
154
+
155
+ assertSame(firstReady, heldReady)
156
+ assertEquals(1, newDirectories().size)
157
+ idle(15_001)
158
+ assertEquals(listOf(true), holdReleases)
159
+ }
160
+
161
+ @Test fun insufficientCapacityFailsWithoutSendingDiagnosticCommands() {
162
+ diagnostics.connected(20)
163
+ start("capture")
164
+ assertEquals(0, writes.size)
165
+ assertTrue(receipt().getString("error").contains("Write capacity"))
166
+ }
167
+
168
+ @Test fun samplePinsPreparedBankAndNeverClaimsStaticPairVerification() {
169
+ assertSample(1)
170
+ }
171
+
172
+ @Test fun sampleAlsoPinsPreparedBankZero() {
173
+ assertSample(0)
174
+ }
175
+
176
+ @Test fun heldSampleStartsOnlyAfterDrainAndReleasesOnSuccess() {
177
+ assertSample(1, "held-sample")
178
+ assertEquals(listOf(true), holdReleases)
179
+ }
180
+
181
+ private fun assertSample(bank: Int, mode: String = "sample") {
182
+ val state = byteArrayOf((1 - bank).toByte(), bank.toByte(), 0, 0)
183
+ start(mode)
184
+ if (mode == "held-sample") {
185
+ assertTrue(writes.isEmpty())
186
+ requireNotNull(heldReady).invoke()
187
+ }
188
+ driveToPageGate()
189
+ diagnostics.onPacket(fbpReply(writes.last().packet, ByteArray(240), state))
190
+ // The gate always probes bank0/page0; the sample follows its prepared selector.
191
+ repeat(315) { page ->
192
+ val request = writes.last().packet
193
+ assertEquals(bank, request[21].toInt())
194
+ assertEquals(page, (request[22].toInt() and 255) or ((request[23].toInt() and 255) shl 8))
195
+ diagnostics.onPacket(fbpReply(request, ByteArray(240) { page.toByte() }, state))
196
+ }
197
+ val result = receipt()
198
+ assertTrue(result.getBoolean("sample_ok"))
199
+ assertFalse(result.getBoolean("ok"))
200
+ assertFalse(result.getBoolean("atomic"))
201
+ assertFalse(result.getBoolean("optical"))
202
+ assertEquals("non_atomic_single_pass", result.getString("evidence_kind"))
203
+ assertEquals(0, result.getInt("static_pairs_verified"))
204
+ assertEquals(1, result.getJSONArray("banks").length())
205
+ assertEquals(319, writes.size)
206
+ assertEquals(638, result.getInt("wire_events"))
207
+ assertFalse(result.getBoolean("pixel_stability_verified"))
208
+ assertEquals(mode, result.getString("mode"))
209
+ assertEquals(75_600, File(captureDirectory(), "bank$bank-pass1.bin").length().toInt())
210
+ }
211
+
212
+ @Test fun sampleRejectsSelectorChangeBetweenGateAndFirstPageWithoutRetrying() {
213
+ start("sample")
214
+ driveToPageGate()
215
+ diagnostics.onPacket(fbpReply(writes.last().packet, ByteArray(240)))
216
+ diagnostics.onPacket(fbpReply(writes.last().packet, ByteArray(240), byteArrayOf(1, 0, 0, 0)))
217
+ val result = receipt()
218
+ assertFalse(result.getBoolean("sample_ok"))
219
+ assertFalse(result.getBoolean("ok"))
220
+ assertTrue(result.getString("error").contains("Sample metadata differs from page gate"))
221
+ assertEquals(5, writes.size)
222
+ assertEquals(0, result.getJSONArray("banks").length())
223
+ }
224
+
225
+ @Test fun sampleTimeoutCannotProduceSuccessOrStartASecondPass() {
226
+ start("sample")
227
+ driveToPageGate()
228
+ diagnostics.onPacket(fbpReply(writes.last().packet, ByteArray(240)))
229
+ writes.last().completed()
230
+ idle(3_001)
231
+ assertFalse(receipt().getBoolean("sample_ok"))
232
+ assertFalse(receipt().getBoolean("ok"))
233
+ assertEquals(5, writes.size)
234
+ }
235
+
236
+ @Test fun fullCaptureStillRequiresFourCompleteReadsAndTwoStaticPairs() {
237
+ start("capture")
238
+ driveToPageGate()
239
+ diagnostics.onPacket(fbpReply(writes.last().packet, ByteArray(240)))
240
+ repeat(4) { pass ->
241
+ repeat(315) { page ->
242
+ val request = writes.last().packet
243
+ assertEquals(pass / 2, request[21].toInt())
244
+ diagnostics.onPacket(fbpReply(request, ByteArray(240) { page.toByte() }))
245
+ }
246
+ if (pass < 3) assertFalse(File(captureDirectory(), "session.json").exists())
247
+ }
248
+ assertTrue(receipt().getBoolean("ok"))
249
+ assertEquals(2, receipt().getInt("static_pairs_verified"))
250
+ assertEquals(4, receipt().getJSONArray("banks").length())
251
+ assertEquals(1264, writes.size)
252
+ }
253
+
254
+ private fun driveToPageGate() {
255
+ assertEquals(1, writes.size)
256
+ assertFalse(diagnostics.onPacket(versionReply()))
257
+ assertEquals(2, writes.size)
258
+ assertFalse(diagnostics.onPacket(frame(0x19, byteArrayOf(0, 0, 1, 0))))
259
+ assertEquals(3, writes.size)
260
+ assertTrue(diagnostics.onPacket(fbpReply(writes[2].packet, "537cf1p1".toByteArray())))
261
+ assertEquals(4, writes.size)
262
+ }
263
+
264
+ // Exercise the production coordinator without pretending Robolectric proves Android's
265
+ // signature-permission enforcement for the ADB receiver. That boundary needs a device test.
266
+ private fun start(mode: String) {
267
+ NimoDiagnostics::class.java.getDeclaredMethod("start", String::class.java).apply { isAccessible = true }
268
+ .invoke(diagnostics, mode)
269
+ }
270
+ private fun idle(milliseconds: Long) = Shadows.shadowOf(Looper.getMainLooper()).idleFor(milliseconds, TimeUnit.MILLISECONDS)
271
+ private fun newDirectories() = root.listFiles()?.filter { it.name !in previousDirectories } ?: emptyList()
272
+ private fun captureDirectory() = newDirectories().single()
273
+ private fun receipt() = JSONObject(File(captureDirectory(), "session.json").readText())
274
+ private fun versionReply() = frame(0x0B, byteArrayOf(0) + NimoFramebufferProtocol.EXPECTED_VERSION.toByteArray())
275
+
276
+ private fun fbpReply(request: ByteArray, data: ByteArray, state: ByteArray = byteArrayOf(0, 1, 0, 0)): ByteArray {
277
+ val payload = byteArrayOf(0) + "FBP1".toByteArray() + request.copyOfRange(16, 24) + state + state +
278
+ byteArrayOf(data.size.toByte(), (data.size shr 8).toByte()) + data
279
+ return frame(0x15, payload)
280
+ }
281
+ private fun frame(key: Int, payload: ByteArray): ByteArray {
282
+ val body = byteArrayOf(2, key.toByte(), payload.size.toByte(), (payload.size shr 8).toByte()) + payload
283
+ val crc = NimoCanvasCodec.crc16(body)
284
+ return byteArrayOf(0xBF.toByte(), 0, body.size.toByte(), (body.size shr 8).toByte(),
285
+ crc.toByte(), (crc shr 8).toByte(), 0, 0) + body
286
+ }
287
+ }
@@ -0,0 +1,127 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class NimoFramebufferProtocolTest {
7
+ @Test fun hexPreservesEveryByteAndEmptyEvidence() {
8
+ val bytes = ByteArray(256) { it.toByte() }
9
+ assertEquals(bytes.joinToString("") { "%02x".format(it.toInt() and 255) }, NimoFramebufferProtocol.hex(bytes))
10
+ assertEquals("", NimoFramebufferProtocol.hex(byteArrayOf()))
11
+ }
12
+ private val pingRequest = NimoFramebufferProtocol.Request(0x01020304L, 0, 0, 0)
13
+ // Fixed vectors produced independently by the existing Python FBP1 codec.
14
+ private val ping = hex("bf0023008234000002151f0000464250310403020100000000000100000001000008003533376366317031")
15
+ private val page = hex("bf000b0175e500000215070100464250310403020101013a010001000000010000f000") +
16
+ ByteArray(240) { it.toByte() }
17
+
18
+ @Test fun requestAndReplyMatchIndependentGoldenVectors() {
19
+ assertArrayEquals(hex("bf001000227a000002150c00464250310403020100000000"),
20
+ NimoFramebufferProtocol.requestFrame(pingRequest))
21
+ val reply = NimoFramebufferProtocol.reply(ping, pingRequest)
22
+ assertArrayEquals("537cf1p1".toByteArray(), reply.data)
23
+ assertEquals(NimoFramebufferProtocol.State(0, 1, 0, 0), reply.before)
24
+ val expectedPage = NimoFramebufferProtocol.Request(0x01020304L, 1, 1, 314)
25
+ assertArrayEquals(ByteArray(240) { it.toByte() }, NimoFramebufferProtocol.reply(page, expectedPage).data)
26
+ }
27
+
28
+ @Test fun rejectsCrcLengthsDirectionIndexAndCommand() {
29
+ for (offset in listOf(0, 1, 2, 4, 6, 8, 9, 10)) {
30
+ val invalid = ping.copyOf().also { it[offset] = (it[offset].toInt() xor 1).toByte() }
31
+ rejects { NimoFramebufferProtocol.reply(invalid, pingRequest) }
32
+ }
33
+ rejects { NimoFramebufferProtocol.reply(ping + 0, pingRequest) }
34
+ rejects { NimoFramebufferProtocol.reply(ping.copyOf(ping.size - 1), pingRequest) }
35
+ }
36
+
37
+ @Test fun rejectsWrongTagMagicNonceOperationBankAndPage() {
38
+ for (offset in listOf(13, 17, 21, 22, 23, 33, 35)) {
39
+ val invalid = changeAndRecrc(ping, offset, ping[offset].toInt() xor 1)
40
+ rejects { NimoFramebufferProtocol.reply(invalid, pingRequest) }
41
+ }
42
+ rejects { NimoFramebufferProtocol.reply(ping, pingRequest.copy(nonce = 9)) }
43
+ rejects { NimoFramebufferProtocol.reply(ping, pingRequest.copy(op = 1)) }
44
+ }
45
+
46
+ @Test fun rejectsStatusErrorsEvenWithValidCorrelation() {
47
+ for (status in listOf(1, 6, 7, 255)) {
48
+ val payload = ping.copyOfRange(12, 35).also { it[0] = status.toByte(); it[21] = 0; it[22] = 0 }
49
+ rejects { NimoFramebufferProtocol.reply(frame(0x15, payload), pingRequest) }
50
+ }
51
+ rejects { NimoFramebufferProtocol.reply(changeAndRecrc(ping, 12, 6), pingRequest) }
52
+ }
53
+
54
+ @Test fun rejectsMetadataChangedWithinAndAcrossPagesAndOutOfOrder() {
55
+ val request = NimoFramebufferProtocol.Request(0x01020304L, 1, 1, 0)
56
+ val first = NimoFramebufferProtocol.reply(pageAt(0), request)
57
+ val read = NimoFramebufferProtocol.BankRead(request.nonce, request.bank)
58
+ read.add(first)
59
+ rejects { read.add(first) }
60
+ rejects { NimoFramebufferProtocol.BankRead(request.nonce, request.bank).add(first.copy(page = 1)) }
61
+ rejects { NimoFramebufferProtocol.reply(changeAndRecrc(pageAt(0), 29, 1), request) }
62
+ val changed = first.copy(page = 1, before = first.before.copy(dirty = 1), after = first.after.copy(dirty = 1))
63
+ val changedRead = NimoFramebufferProtocol.BankRead(request.nonce, request.bank)
64
+ changedRead.add(first)
65
+ rejects { changedRead.add(changed) }
66
+ }
67
+
68
+ @Test fun bankRequiresEveryPageAndIndependentEqualSecondPass() {
69
+ val first = completeBank(10)
70
+ val second = completeBank(11)
71
+ NimoFramebufferProtocol.verifyPair(first, second)
72
+ assertEquals(75600, first.bytes().size)
73
+ rejects { NimoFramebufferProtocol.verifyPair(first, first) }
74
+ rejects { NimoFramebufferProtocol.verifyPair(first, completeBank(12, changedLastPixel = true)) }
75
+ rejects { NimoFramebufferProtocol.BankRead(20, 1).bytes() }
76
+ }
77
+
78
+ @Test fun requestLimitsPreventArbitraryCommandsOrAddresses() {
79
+ for (request in listOf(pingRequest.copy(nonce = -1), pingRequest.copy(nonce = 0x100000000L),
80
+ pingRequest.copy(op = 2), pingRequest.copy(bank = 2), pingRequest.copy(page = 1),
81
+ pingRequest.copy(op = 1, page = 315))) {
82
+ rejects { NimoFramebufferProtocol.requestFrame(request) }
83
+ }
84
+ rejects { NimoFramebufferProtocol.queryFrame(0x0A) }
85
+ }
86
+
87
+ @Test fun basicResponsesRequireExactVersionAndReadinessShape() {
88
+ val version = byteArrayOf(0) + NimoFramebufferProtocol.EXPECTED_VERSION.toByteArray()
89
+ assertEquals(NimoFramebufferProtocol.EXPECTED_VERSION,
90
+ NimoFramebufferProtocol.version(frame(0x0B, version)))
91
+ rejects { NimoFramebufferProtocol.version(frame(0x0B, version + 0)) }
92
+ rejects { NimoFramebufferProtocol.version(frame(0x0B, version.copyOf().also { it[0] = 7 })) }
93
+ assertEquals(listOf(0, 0, 1, 0), NimoFramebufferProtocol.readiness(frame(0x19, byteArrayOf(0, 0, 1, 0))))
94
+ rejects { NimoFramebufferProtocol.readiness(frame(0x19, byteArrayOf(0, 1, 1))) }
95
+ }
96
+
97
+ private fun completeBank(nonce: Long, changedLastPixel: Boolean = false): NimoFramebufferProtocol.BankRead {
98
+ val read = NimoFramebufferProtocol.BankRead(nonce, 1)
99
+ repeat(315) { index ->
100
+ val original = NimoFramebufferProtocol.reply(pageAt(index),
101
+ NimoFramebufferProtocol.Request(0x01020304L, 1, 1, index))
102
+ val data = original.data.copyOf()
103
+ if (changedLastPixel && index == 314) data[239] = 0
104
+ read.add(original.copy(nonce = nonce, data = data))
105
+ }
106
+ return read
107
+ }
108
+
109
+ private fun pageAt(index: Int): ByteArray {
110
+ val result = page.copyOf()
111
+ result[23] = index.toByte(); result[24] = (index shr 8).toByte()
112
+ return recrc(result)
113
+ }
114
+ private fun changeAndRecrc(value: ByteArray, index: Int, byte: Int) =
115
+ recrc(value.copyOf().also { it[index] = byte.toByte() })
116
+ private fun recrc(value: ByteArray): ByteArray {
117
+ val crc = NimoCanvasCodec.crc16(value.copyOfRange(8, value.size))
118
+ value[4] = crc.toByte(); value[5] = (crc shr 8).toByte()
119
+ return value
120
+ }
121
+ private fun frame(key: Int, payload: ByteArray): ByteArray {
122
+ val body = byteArrayOf(2, key.toByte(), payload.size.toByte(), (payload.size shr 8).toByte()) + payload
123
+ return recrc(byteArrayOf(0xBF.toByte(), 0, body.size.toByte(), (body.size shr 8).toByte(), 0, 0, 0, 0) + body)
124
+ }
125
+ private fun rejects(block: () -> Unit) { assertThrows(IllegalArgumentException::class.java, block) }
126
+ private fun hex(value: String) = value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
127
+ }