@mentra/bluetooth-sdk 3.2.1-dev.314 → 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.
- package/README.md +4 -4
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
- package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
- 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/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
- package/build/BluetoothSdk.types.d.ts +13 -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 +4 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +3 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +2 -2
- package/build/generated/changelogCatalog.js +2 -2
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +9 -0
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothBackgroundMode.swift +32 -0
- package/ios/Source/BluetoothSdkDefaults.swift +6 -1
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +96 -63
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +18 -0
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/requests/DisplayRequests.swift +1 -1
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/G1.swift +68 -38
- package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
- package/ios/Source/sgcs/G2.swift +261 -247
- package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
- package/ios/Source/sgcs/Mach1.swift +19 -7
- package/ios/Source/sgcs/MentraLive.swift +201 -156
- package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
- package/ios/Source/sgcs/MentraNex.swift +8 -31
- package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
- package/ios/Source/sgcs/Nimo.swift +280 -408
- package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
- package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
- package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
- package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
- package/ios/Source/sgcs/SGCManager.swift +24 -0
- package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -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/Source/utils/G2Text.swift +11 -0
- package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
- package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
- package/ios/Tests/DashboardRenderingTests.swift +31 -0
- package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
- package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
- package/ios/Tests/G1PairingTests.swift +162 -0
- package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
- package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
- package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
- package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
- package/ios/Tests/NimoDiscoveryTests.swift +79 -0
- package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
- package/ios/Tests/VersionInfoCommandTests.swift +53 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
- package/package.json +2 -2
- package/plugin/build/withIos.d.ts +2 -0
- package/plugin/build/withIos.js +12 -1
- package/src/BluetoothSdk.types.ts +14 -0
- package/src/_private/BluetoothSdkModule.ts +8 -0
- package/src/generated/changelogCatalog.ts +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
- 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;
|