@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,288 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothGattCharacteristic
5
+ import org.robolectric.shadows.ShadowBluetoothGatt
6
+ import android.content.BroadcastReceiver
7
+ import android.content.Context
8
+ import android.content.ContextWrapper
9
+ import android.content.SharedPreferences
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import androidx.test.core.app.ApplicationProvider
13
+ import com.mentra.bluetoothsdk.Bridge
14
+ import com.mentra.bluetoothsdk.DeviceManager
15
+ import com.mentra.bluetoothsdk.DeviceStore
16
+ import com.mentra.bluetoothsdk.utils.ConnTypes
17
+ import java.io.File
18
+ import java.util.concurrent.TimeUnit
19
+ import java.util.concurrent.atomic.AtomicReference
20
+ import org.json.JSONObject
21
+ import org.junit.After
22
+ import org.junit.Assert.*
23
+ import org.junit.Before
24
+ import org.junit.Test
25
+ import org.junit.runner.RunWith
26
+ import org.robolectric.RobolectricTestRunner
27
+ import org.robolectric.Shadows
28
+ import org.robolectric.annotation.Config
29
+ import org.robolectric.annotation.LooperMode
30
+
31
+ @RunWith(RobolectricTestRunner::class)
32
+ @Config(sdk = [33])
33
+ @LooperMode(LooperMode.Mode.PAUSED)
34
+ class NimoLifecycleTest {
35
+ private class TrackingContext(base: Context) : ContextWrapper(base) {
36
+ val unregisterLoopers = mutableListOf<Looper?>()
37
+ val preferenceLoopers = mutableListOf<Looper?>()
38
+ override fun getApplicationContext(): Context = this
39
+ override fun getSharedPreferences(name: String, mode: Int): SharedPreferences {
40
+ if (name == "NimoPrefs") preferenceLoopers.add(Looper.myLooper())
41
+ return super.getSharedPreferences(name, mode)
42
+ }
43
+ override fun unregisterReceiver(receiver: BroadcastReceiver) {
44
+ unregisterLoopers.add(Looper.myLooper())
45
+ super.unregisterReceiver(receiver)
46
+ }
47
+ }
48
+
49
+ private lateinit var context: TrackingContext
50
+ private lateinit var nimo: Nimo
51
+ private lateinit var encoderThread: Thread
52
+ private lateinit var manager: DeviceManager
53
+ private var previousManager: DeviceManager? = null
54
+ private lateinit var diagnostics: NimoDiagnostics
55
+ private lateinit var captureDirectory: File
56
+ private lateinit var writeCompleted: () -> Unit
57
+ private var writes = 0
58
+ private val storeLoopers = mutableListOf<Looper?>()
59
+ private var listener: String? = null
60
+
61
+ @Before fun setup() {
62
+ context = TrackingContext(ApplicationProvider.getApplicationContext())
63
+ Bridge.initialize(context)
64
+ manager = DeviceManager(initializeHardware = false)
65
+ DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }.let {
66
+ previousManager = it.get(null) as DeviceManager?
67
+ it.set(null, manager)
68
+ }
69
+ val threadsBefore = Thread.getAllStackTraces().keys
70
+ nimo = Nimo()
71
+ encoderThread = Thread.getAllStackTraces().keys.single {
72
+ it.name == "NimoCanvasEncoder" && it !in threadsBefore
73
+ }
74
+ context.getSharedPreferences("NimoPrefs", Context.MODE_PRIVATE).edit()
75
+ .putString("nimo_lastDeviceAddress", "00:11:22:33:44:55")
76
+ .putString("nimo_lastDeviceName", "Nimo-test")
77
+ .commit()
78
+ DeviceStore.set("glasses", "connected", true)
79
+ DeviceStore.set("glasses", "fullyBooted", true)
80
+ DeviceStore.set("glasses", "connectionState", ConnTypes.CONNECTED)
81
+ listener = DeviceStore.store.addListener { category, _ ->
82
+ if (category == "glasses") storeLoopers.add(Looper.myLooper())
83
+ }
84
+ diagnostics = NimoDiagnostics(context, Handler(Looper.getMainLooper()), send = { _, completed ->
85
+ writes += 1
86
+ writeCompleted = completed
87
+ true
88
+ })
89
+ diagnostics.connected(512)
90
+ // Install the real debug collaborator without requiring a Bluetooth radio handshake.
91
+ Nimo::class.java.getDeclaredField("diagnostics").apply { isAccessible = true }
92
+ .set(nimo, diagnostics)
93
+ val root = File(context.filesDir, "nimo-framebuffer")
94
+ val previous = root.listFiles()?.map { it.name }?.toSet() ?: emptySet()
95
+ // Receiver permission enforcement is a device test; this starts its real capture path.
96
+ NimoDiagnostics::class.java.getDeclaredMethod("start", String::class.java)
97
+ .apply { isAccessible = true }.invoke(diagnostics, "capture")
98
+ captureDirectory = root.listFiles()!!.single { it.name !in previous }
99
+ assertEquals(1, writes)
100
+ }
101
+
102
+ @After fun dispose() {
103
+ nimo.cleanup()
104
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
105
+ encoderThread.join(1_000)
106
+ listener?.let { DeviceStore.store.removeListener(it) }
107
+ manager.cleanup()
108
+ DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
109
+ .set(null, previousManager)
110
+ }
111
+
112
+ @Test fun offMainDisconnectCompletesOnMainAndInterruptsActiveCapture() {
113
+ runOffMain { nimo.disconnect() }
114
+ assertDisconnectedOnMain()
115
+ assertRememberedDevice()
116
+ }
117
+
118
+ @Test fun offMainForgetClearsRememberedDeviceAfterMainThreadTeardown() {
119
+ runOffMain { nimo.forget() }
120
+ assertDisconnectedOnMain()
121
+ val prefs = context.getSharedPreferences("NimoPrefs", Context.MODE_PRIVATE)
122
+ assertFalse(prefs.contains("nimo_lastDeviceAddress"))
123
+ assertFalse(prefs.contains("nimo_lastDeviceName"))
124
+ assertTrue(context.preferenceLoopers.all { it === Looper.getMainLooper() })
125
+ }
126
+
127
+ @Test fun offMainCleanupClosesDiagnosticsAfterMainThreadTeardown() {
128
+ runOffMain { nimo.cleanup() }
129
+ assertDisconnectedOnMain()
130
+ assertRememberedDevice()
131
+ encoderThread.join(1_000)
132
+ assertFalse("Cleanup left its encoder thread alive", encoderThread.isAlive)
133
+ // A closed diagnostic cannot register another receiver or start a new capture.
134
+ diagnostics.connected(512)
135
+ NimoDiagnostics::class.java.getDeclaredMethod("start", String::class.java)
136
+ .apply { isAccessible = true }.invoke(diagnostics, "capture")
137
+ assertEquals(1, writes)
138
+ }
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
+
231
+ private fun runOffMain(operation: () -> Unit) {
232
+ val failure = AtomicReference<Throwable?>()
233
+ val caller = Thread({
234
+ try {
235
+ operation()
236
+ // The manager may replace this instance immediately after return. An asynchronous
237
+ // post is insufficient: the old connection must already be completely torn down.
238
+ assertEquals(false, DeviceStore.get("glasses", "connected"))
239
+ assertEquals(false, DeviceStore.get("glasses", "fullyBooted"))
240
+ assertTrue(File(captureDirectory, "session.json").exists())
241
+ } catch (error: Throwable) { failure.set(error) }
242
+ }, "Expo-AsyncFunctionQueue-test")
243
+ caller.start()
244
+ // The Main looper is paused, so no teardown may have run on the Expo worker.
245
+ assertEquals(true, DeviceStore.get("glasses", "connected"))
246
+ assertEquals(true, DeviceStore.get("glasses", "fullyBooted"))
247
+ assertRememberedDevice()
248
+ assertTrue(context.unregisterLoopers.isEmpty())
249
+ assertTrue(storeLoopers.isEmpty())
250
+ assertTrue("Encoder stopped before Main processed teardown", encoderThread.isAlive)
251
+ assertFalse(File(captureDirectory, "session.json").exists())
252
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
253
+ while (caller.isAlive && System.nanoTime() < deadline) {
254
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
255
+ caller.join(10)
256
+ }
257
+ if (caller.isAlive) {
258
+ caller.interrupt()
259
+ caller.join(1_000)
260
+ fail("Lifecycle did not return after Main processed teardown")
261
+ }
262
+ failure.get()?.let { throw AssertionError("Off-main lifecycle entrypoint failed", it) }
263
+ }
264
+
265
+ private fun assertDisconnectedOnMain() {
266
+ assertEquals(false, DeviceStore.get("glasses", "connected"))
267
+ assertEquals(false, DeviceStore.get("glasses", "fullyBooted"))
268
+ assertEquals(ConnTypes.DISCONNECTED, DeviceStore.get("glasses", "connectionState"))
269
+ assertEquals(listOf(Looper.getMainLooper()), context.unregisterLoopers)
270
+ assertTrue(storeLoopers.isNotEmpty())
271
+ assertTrue(storeLoopers.all { it === Looper.getMainLooper() })
272
+ val receiptFile = File(captureDirectory, "session.json")
273
+ val beforeLateCallback = receiptFile.readText()
274
+ val receipt = JSONObject(beforeLateCallback)
275
+ assertFalse(receipt.getBoolean("ok"))
276
+ assertTrue(receipt.getString("error").contains("disconnected"))
277
+ writeCompleted()
278
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
279
+ assertEquals(beforeLateCallback, receiptFile.readText())
280
+ assertEquals(1, writes)
281
+ }
282
+
283
+ private fun assertRememberedDevice() {
284
+ val prefs = context.getSharedPreferences("NimoPrefs", Context.MODE_PRIVATE)
285
+ assertEquals("00:11:22:33:44:55", prefs.getString("nimo_lastDeviceAddress", null))
286
+ assertEquals("Nimo-test", prefs.getString("nimo_lastDeviceName", null))
287
+ }
288
+ }
@@ -0,0 +1,205 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothGatt
5
+ import android.bluetooth.BluetoothGattCallback
6
+ import android.bluetooth.BluetoothGattCharacteristic
7
+ import android.bluetooth.BluetoothGattService
8
+ import android.content.Context
9
+ import android.os.Handler
10
+ import android.os.Looper
11
+ import androidx.test.core.app.ApplicationProvider
12
+ import com.mentra.bluetoothsdk.Bridge
13
+ import com.mentra.bluetoothsdk.DeviceManager
14
+ import com.mentra.bluetoothsdk.DeviceStore
15
+ import com.mentra.bluetoothsdk.utils.ConnTypes
16
+ import java.util.UUID
17
+ import org.junit.After
18
+ import org.junit.Assert.*
19
+ import org.junit.Before
20
+ import org.junit.Test
21
+ import org.junit.runner.RunWith
22
+ import org.robolectric.RobolectricTestRunner
23
+ import org.robolectric.Shadows
24
+ import org.robolectric.annotation.Config
25
+ import org.robolectric.annotation.LooperMode
26
+ import org.robolectric.shadows.ShadowBluetoothGatt
27
+
28
+ /** Exercise real receive dispatch, not just the already-tested canvas state machine. */
29
+ @RunWith(RobolectricTestRunner::class)
30
+ @Config(sdk = [33])
31
+ @LooperMode(LooperMode.Mode.PAUSED)
32
+ @Suppress("deprecation")
33
+ class NimoReadinessDriverTest {
34
+ private lateinit var nimo: Nimo
35
+ private lateinit var encoderThread: Thread
36
+ private lateinit var manager: DeviceManager
37
+ private var previousManager: DeviceManager? = null
38
+ private lateinit var callback: BluetoothGattCallback
39
+ private lateinit var currentGatt: BluetoothGatt
40
+ private lateinit var rx: BluetoothGattCharacteristic
41
+ private lateinit var canvas: NimoCanvasCoordinator
42
+ private val frames = mutableListOf<ByteArray>()
43
+ private val failures = mutableListOf<String>()
44
+ private val rejections = mutableListOf<Int>()
45
+ private val savedStore = mutableMapOf<String, Any>()
46
+
47
+ @Before fun setup() {
48
+ Bridge.initialize(ApplicationProvider.getApplicationContext<Context>())
49
+ manager = DeviceManager(initializeHardware = false)
50
+ DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }.let {
51
+ previousManager = it.get(null) as DeviceManager?
52
+ it.set(null, manager)
53
+ }
54
+ for (key in listOf("connected", "fullyBooted", "connectionState", "headUp", "voiceActivityDetectionEnabled")) {
55
+ DeviceStore.get("glasses", key)?.let { savedStore[key] = it }
56
+ }
57
+ // Suppress unrelated manager side effects when finishHandshake republishes these values.
58
+ DeviceStore.set("glasses", "connected", true)
59
+ DeviceStore.set("glasses", "fullyBooted", true)
60
+ DeviceStore.set("glasses", "connectionState", ConnTypes.CONNECTED)
61
+ val before = Thread.getAllStackTraces().keys
62
+ nimo = Nimo()
63
+ encoderThread = Thread.getAllStackTraces().keys.single {
64
+ it.name == "NimoCanvasEncoder" && it !in before
65
+ }
66
+ callback = field("gattCallback").get(nimo) as BluetoothGattCallback
67
+ currentGatt = newGatt()
68
+ field("gatt").set(nimo, currentGatt)
69
+ rx = characteristic(NimoBLE.SERVICE_UUID)
70
+ val handler = Handler(Looper.getMainLooper())
71
+ canvas = NimoCanvasCoordinator(NimoScheduler { delay, task ->
72
+ val runnable = Runnable(task)
73
+ handler.postDelayed(runnable, delay)
74
+ ({ handler.removeCallbacks(runnable) })
75
+ }, { 512 }, { chain, started, completed ->
76
+ frames.addAll(chain.map { it.copyOf() })
77
+ started()
78
+ completed()
79
+ true
80
+ }, { failures.add(it) }, { rejections.add(it) })
81
+ field("canvas").set(nimo, canvas)
82
+ }
83
+
84
+ @After fun dispose() {
85
+ try {
86
+ nimo.cleanup()
87
+ idle()
88
+ encoderThread.join(1_000)
89
+ assertFalse("Encoder thread leaked", encoderThread.isAlive)
90
+ } finally {
91
+ manager.cleanup()
92
+ savedStore.forEach { (key, value) -> DeviceStore.set("glasses", key, value) }
93
+ DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
94
+ .set(null, previousManager)
95
+ }
96
+ }
97
+
98
+ @Test fun onlyCompleteSuccessfulHeartbeatReleasesNotReadyAndSendsLatestScene() {
99
+ val latest = byteArrayOf(1, 0, 1)
100
+ canvas.offer(byteArrayOf(0, 0, 1), "first")
101
+ finishHandshake(peer = true)
102
+ assertKeys(1)
103
+ emit(packet(7, 1, byteArrayOf(7)))
104
+ assertEquals(listOf(7), rejections)
105
+ canvas.offer(latest, "latest")
106
+
107
+ emit(packet(6, 3, byteArrayOf(0, 1))) // TWS alone cannot refresh peer readiness.
108
+ emit(heartbeat(status = 7))
109
+ emit(packet(6, 4, byteArrayOf(0, 3) + ByteArray(9))) // Missing slaveGatt.
110
+ val corrupt = heartbeat().also { it[it.lastIndex] = 2 } // Invalid CRC.
111
+ emit(corrupt)
112
+ assertKeys(1)
113
+
114
+ emit(heartbeat())
115
+ assertKeys(1, 1)
116
+ emit(heartbeat()) // No overlapping Launch while awaiting its ACK.
117
+ assertKeys(1, 1)
118
+ emit(packet(7, 1, byteArrayOf(0, 0xFD.toByte())))
119
+ assertKeys(1, 1, 4)
120
+ assertArrayEquals(NimoCanvasCodec.frames(4, latest, 512).single(), frames.last())
121
+ assertTrue(failures.isEmpty())
122
+ }
123
+
124
+ @Test fun freshHandshakeProbesCanvasWithoutWaitingForHeartbeat() {
125
+ finishHandshake(peer = null)
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)
138
+ assertKeys()
139
+ emit(packet(6, 3, byteArrayOf(0, 1)))
140
+ assertKeys()
141
+ emit(heartbeat())
142
+ assertKeys(1)
143
+ assertTrue(failures.isEmpty())
144
+ }
145
+
146
+ @Test fun queuedOldGattAndWrongServiceCannotEstablishReadiness() {
147
+ canvas.offer(byteArrayOf(0, 0, 1), "initial")
148
+ finishHandshake(peer = false)
149
+ val oldGatt = currentGatt
150
+ queue(heartbeat(), oldGatt)
151
+ currentGatt = newGatt()
152
+ field("gatt").set(nimo, currentGatt)
153
+ idle() // The generation check must occur when the queued callback executes.
154
+ emit(heartbeat(), oldGatt)
155
+ emit(heartbeat(), currentGatt, characteristic(UUID.fromString("00001234-0000-1000-8000-00805f9b34fb")))
156
+ assertEquals(false, field("peerCompanionReady").get(nimo))
157
+ assertKeys()
158
+
159
+ emit(heartbeat()) // Positive control through the actual current RX callback.
160
+ assertKeys(1)
161
+ oldGatt.close()
162
+ assertTrue(failures.isEmpty())
163
+ }
164
+
165
+ private fun finishHandshake(peer: Boolean?) {
166
+ field("twsConnected").set(nimo, true)
167
+ field("peerCompanionReady").set(nimo, peer)
168
+ Nimo::class.java.getDeclaredMethod("finishHandshake").apply { isAccessible = true }.invoke(nimo)
169
+ idle()
170
+ }
171
+
172
+ private fun newGatt(): BluetoothGatt = ShadowBluetoothGatt.newInstance(
173
+ BluetoothAdapter.getDefaultAdapter().getRemoteDevice("00:11:22:33:44:55"),
174
+ )
175
+
176
+ private fun characteristic(serviceId: UUID): BluetoothGattCharacteristic {
177
+ val characteristic = BluetoothGattCharacteristic(NimoBLE.CHAR_RX,
178
+ BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ)
179
+ BluetoothGattService(serviceId, BluetoothGattService.SERVICE_TYPE_PRIMARY).addCharacteristic(characteristic)
180
+ return characteristic
181
+ }
182
+
183
+ private fun heartbeat(status: Int = 0, peer: Int = 1) = packet(6, 4,
184
+ byteArrayOf(status.toByte(), 3) + ByteArray(8) + byteArrayOf(1, peer.toByte()))
185
+
186
+ private fun packet(command: Int, key: Int, payload: ByteArray) =
187
+ NimoFrameCodec.encodeFrame(command, key, payload, needsAck = false)
188
+
189
+ private fun queue(packet: ByteArray, gatt: BluetoothGatt = currentGatt,
190
+ characteristic: BluetoothGattCharacteristic = rx) {
191
+ characteristic.value = packet.copyOf()
192
+ callback.onCharacteristicChanged(gatt, characteristic)
193
+ }
194
+
195
+ private fun emit(packet: ByteArray, gatt: BluetoothGatt = currentGatt,
196
+ characteristic: BluetoothGattCharacteristic = rx) {
197
+ queue(packet, gatt, characteristic)
198
+ idle()
199
+ }
200
+
201
+ private fun idle() = Shadows.shadowOf(Looper.getMainLooper()).idle()
202
+ private fun field(name: String) = Nimo::class.java.getDeclaredField(name).apply { isAccessible = true }
203
+ private fun assertKeys(vararg expected: Int) =
204
+ assertEquals(expected.toList(), frames.map { it[9].toInt() and 255 })
205
+ }
@@ -444,6 +444,15 @@ export type WearTuning = {
444
444
  export type WearStateEvent = {
445
445
  type: "wear_state";
446
446
  worn: boolean;
447
+ /** Unworn countdown, present on firmware that reports it. Milliseconds. */
448
+ elapsedMs?: number;
449
+ timeoutMs?: number;
450
+ /** cs_swit type 11. Absent on older firmware. */
451
+ enabled?: boolean;
452
+ /** Countdown is running (glasses are off the face and the feature is on). */
453
+ armed?: boolean;
454
+ /** Clock is running, but charging, a call, OTA, or factory test is holding shutdown. */
455
+ inhibited?: boolean;
447
456
  };
448
457
  /**
449
458
  * What the wear poll loop is actually running. A rejected patch echoes the
@@ -1204,6 +1213,7 @@ export interface BluetoothSdkPublicModule {
1204
1213
  setGalleryModeEnabled(enabled: boolean): Promise<SettingsAckSuccessEvent>;
1205
1214
  setVoiceActivityDetectionEnabled(enabled: boolean): Promise<void>;
1206
1215
  setLoudnessGateEnabled(enabled: boolean): Promise<void>;
1216
+ setAutoPowerOffEnabled(enabled: boolean): Promise<void>;
1207
1217
  /**
1208
1218
  * @deprecated Sticky action-button photo presets are deprecated. Prefer per-request
1209
1219
  * `requestPhoto(...)` options (e.g. `mode: "text"` for text sensor size/crop, or explicit per-shot
@@ -1331,6 +1341,8 @@ export interface OtaProgress {
1331
1341
  errorMessage?: string;
1332
1342
  }
1333
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;
1334
1346
  connection: GlassesConnectionStatus;
1335
1347
  micEnabled: boolean;
1336
1348
  voiceActivityDetectionEnabled: boolean;
@@ -1505,6 +1517,7 @@ export type BluetoothSettingsUpdate = Partial<{
1505
1517
  gallery_mode: boolean;
1506
1518
  voice_activity_detection_enabled: boolean;
1507
1519
  loudness_gate_enabled: boolean;
1520
+ auto_power_off_enabled: boolean;
1508
1521
  /** Effective mic tuning only. `{}` means "reset to firmware defaults". */
1509
1522
  mic_tuning: MicTuning;
1510
1523
  button_photo_size: ButtonPhotoSize;