@mentra/bluetooth-sdk 3.3.0-dev.316 → 3.3.0-dev.320
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/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/DeviceManager.kt +37 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -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 +225 -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 +168 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -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/DeviceManagerSceneHandoffTest.kt +557 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -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 +308 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -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 +194 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +23 -10
- package/ios/Source/DeviceStore.swift +19 -7
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/sgcs/G2.swift +2 -2
- package/ios/Source/sgcs/Nimo.swift +182 -371
- package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
- package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
- package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
- package/ios/Source/sgcs/SGCManager.swift +16 -0
- package/ios/Source/utils/G2Text.swift +11 -0
- package/ios/Tests/DashboardRenderingTests.swift +31 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
- package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
- package/package.json +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.ContextWrapper
|
|
6
|
+
import android.content.SharedPreferences
|
|
7
|
+
import android.os.Handler
|
|
8
|
+
import android.os.Looper
|
|
9
|
+
import androidx.test.core.app.ApplicationProvider
|
|
10
|
+
import com.mentra.bluetoothsdk.Bridge
|
|
11
|
+
import com.mentra.bluetoothsdk.DeviceManager
|
|
12
|
+
import com.mentra.bluetoothsdk.DeviceStore
|
|
13
|
+
import com.mentra.bluetoothsdk.utils.ConnTypes
|
|
14
|
+
import java.io.File
|
|
15
|
+
import java.util.concurrent.TimeUnit
|
|
16
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
17
|
+
import org.json.JSONObject
|
|
18
|
+
import org.junit.After
|
|
19
|
+
import org.junit.Assert.*
|
|
20
|
+
import org.junit.Before
|
|
21
|
+
import org.junit.Test
|
|
22
|
+
import org.junit.runner.RunWith
|
|
23
|
+
import org.robolectric.RobolectricTestRunner
|
|
24
|
+
import org.robolectric.Shadows
|
|
25
|
+
import org.robolectric.annotation.Config
|
|
26
|
+
import org.robolectric.annotation.LooperMode
|
|
27
|
+
|
|
28
|
+
@RunWith(RobolectricTestRunner::class)
|
|
29
|
+
@Config(sdk = [33])
|
|
30
|
+
@LooperMode(LooperMode.Mode.PAUSED)
|
|
31
|
+
class NimoLifecycleTest {
|
|
32
|
+
private class TrackingContext(base: Context) : ContextWrapper(base) {
|
|
33
|
+
val unregisterLoopers = mutableListOf<Looper?>()
|
|
34
|
+
val preferenceLoopers = mutableListOf<Looper?>()
|
|
35
|
+
override fun getApplicationContext(): Context = this
|
|
36
|
+
override fun getSharedPreferences(name: String, mode: Int): SharedPreferences {
|
|
37
|
+
if (name == "NimoPrefs") preferenceLoopers.add(Looper.myLooper())
|
|
38
|
+
return super.getSharedPreferences(name, mode)
|
|
39
|
+
}
|
|
40
|
+
override fun unregisterReceiver(receiver: BroadcastReceiver) {
|
|
41
|
+
unregisterLoopers.add(Looper.myLooper())
|
|
42
|
+
super.unregisterReceiver(receiver)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private lateinit var context: TrackingContext
|
|
47
|
+
private lateinit var nimo: Nimo
|
|
48
|
+
private lateinit var encoderThread: Thread
|
|
49
|
+
private lateinit var manager: DeviceManager
|
|
50
|
+
private var previousManager: DeviceManager? = null
|
|
51
|
+
private lateinit var diagnostics: NimoDiagnostics
|
|
52
|
+
private lateinit var captureDirectory: File
|
|
53
|
+
private lateinit var writeCompleted: () -> Unit
|
|
54
|
+
private var writes = 0
|
|
55
|
+
private val storeLoopers = mutableListOf<Looper?>()
|
|
56
|
+
private var listener: String? = null
|
|
57
|
+
|
|
58
|
+
@Before fun setup() {
|
|
59
|
+
context = TrackingContext(ApplicationProvider.getApplicationContext())
|
|
60
|
+
Bridge.initialize(context)
|
|
61
|
+
manager = DeviceManager(initializeHardware = false)
|
|
62
|
+
DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }.let {
|
|
63
|
+
previousManager = it.get(null) as DeviceManager?
|
|
64
|
+
it.set(null, manager)
|
|
65
|
+
}
|
|
66
|
+
val threadsBefore = Thread.getAllStackTraces().keys
|
|
67
|
+
nimo = Nimo()
|
|
68
|
+
encoderThread = Thread.getAllStackTraces().keys.single {
|
|
69
|
+
it.name == "NimoCanvasEncoder" && it !in threadsBefore
|
|
70
|
+
}
|
|
71
|
+
context.getSharedPreferences("NimoPrefs", Context.MODE_PRIVATE).edit()
|
|
72
|
+
.putString("nimo_lastDeviceAddress", "00:11:22:33:44:55")
|
|
73
|
+
.putString("nimo_lastDeviceName", "Nimo-test")
|
|
74
|
+
.commit()
|
|
75
|
+
DeviceStore.set("glasses", "connected", true)
|
|
76
|
+
DeviceStore.set("glasses", "fullyBooted", true)
|
|
77
|
+
DeviceStore.set("glasses", "connectionState", ConnTypes.CONNECTED)
|
|
78
|
+
listener = DeviceStore.store.addListener { category, _ ->
|
|
79
|
+
if (category == "glasses") storeLoopers.add(Looper.myLooper())
|
|
80
|
+
}
|
|
81
|
+
diagnostics = NimoDiagnostics(context, Handler(Looper.getMainLooper()), send = { _, completed ->
|
|
82
|
+
writes += 1
|
|
83
|
+
writeCompleted = completed
|
|
84
|
+
true
|
|
85
|
+
})
|
|
86
|
+
diagnostics.connected(512)
|
|
87
|
+
// Install the real debug collaborator without requiring a Bluetooth radio handshake.
|
|
88
|
+
Nimo::class.java.getDeclaredField("diagnostics").apply { isAccessible = true }
|
|
89
|
+
.set(nimo, diagnostics)
|
|
90
|
+
val root = File(context.filesDir, "nimo-framebuffer")
|
|
91
|
+
val previous = root.listFiles()?.map { it.name }?.toSet() ?: emptySet()
|
|
92
|
+
// Receiver permission enforcement is a device test; this starts its real capture path.
|
|
93
|
+
NimoDiagnostics::class.java.getDeclaredMethod("start", String::class.java)
|
|
94
|
+
.apply { isAccessible = true }.invoke(diagnostics, "capture")
|
|
95
|
+
captureDirectory = root.listFiles()!!.single { it.name !in previous }
|
|
96
|
+
assertEquals(1, writes)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@After fun dispose() {
|
|
100
|
+
nimo.cleanup()
|
|
101
|
+
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
|
102
|
+
encoderThread.join(1_000)
|
|
103
|
+
listener?.let { DeviceStore.store.removeListener(it) }
|
|
104
|
+
manager.cleanup()
|
|
105
|
+
DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
|
|
106
|
+
.set(null, previousManager)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@Test fun offMainDisconnectCompletesOnMainAndInterruptsActiveCapture() {
|
|
110
|
+
runOffMain { nimo.disconnect() }
|
|
111
|
+
assertDisconnectedOnMain()
|
|
112
|
+
assertRememberedDevice()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@Test fun offMainForgetClearsRememberedDeviceAfterMainThreadTeardown() {
|
|
116
|
+
runOffMain { nimo.forget() }
|
|
117
|
+
assertDisconnectedOnMain()
|
|
118
|
+
val prefs = context.getSharedPreferences("NimoPrefs", Context.MODE_PRIVATE)
|
|
119
|
+
assertFalse(prefs.contains("nimo_lastDeviceAddress"))
|
|
120
|
+
assertFalse(prefs.contains("nimo_lastDeviceName"))
|
|
121
|
+
assertTrue(context.preferenceLoopers.all { it === Looper.getMainLooper() })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@Test fun offMainCleanupClosesDiagnosticsAfterMainThreadTeardown() {
|
|
125
|
+
runOffMain { nimo.cleanup() }
|
|
126
|
+
assertDisconnectedOnMain()
|
|
127
|
+
assertRememberedDevice()
|
|
128
|
+
encoderThread.join(1_000)
|
|
129
|
+
assertFalse("Cleanup left its encoder thread alive", encoderThread.isAlive)
|
|
130
|
+
// A closed diagnostic cannot register another receiver or start a new capture.
|
|
131
|
+
diagnostics.connected(512)
|
|
132
|
+
NimoDiagnostics::class.java.getDeclaredMethod("start", String::class.java)
|
|
133
|
+
.apply { isAccessible = true }.invoke(diagnostics, "capture")
|
|
134
|
+
assertEquals(1, writes)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private fun runOffMain(operation: () -> Unit) {
|
|
138
|
+
val failure = AtomicReference<Throwable?>()
|
|
139
|
+
val caller = Thread({
|
|
140
|
+
try {
|
|
141
|
+
operation()
|
|
142
|
+
// The manager may replace this instance immediately after return. An asynchronous
|
|
143
|
+
// post is insufficient: the old connection must already be completely torn down.
|
|
144
|
+
assertEquals(false, DeviceStore.get("glasses", "connected"))
|
|
145
|
+
assertEquals(false, DeviceStore.get("glasses", "fullyBooted"))
|
|
146
|
+
assertTrue(File(captureDirectory, "session.json").exists())
|
|
147
|
+
} catch (error: Throwable) { failure.set(error) }
|
|
148
|
+
}, "Expo-AsyncFunctionQueue-test")
|
|
149
|
+
caller.start()
|
|
150
|
+
// The Main looper is paused, so no teardown may have run on the Expo worker.
|
|
151
|
+
assertEquals(true, DeviceStore.get("glasses", "connected"))
|
|
152
|
+
assertEquals(true, DeviceStore.get("glasses", "fullyBooted"))
|
|
153
|
+
assertRememberedDevice()
|
|
154
|
+
assertTrue(context.unregisterLoopers.isEmpty())
|
|
155
|
+
assertTrue(storeLoopers.isEmpty())
|
|
156
|
+
assertTrue("Encoder stopped before Main processed teardown", encoderThread.isAlive)
|
|
157
|
+
assertFalse(File(captureDirectory, "session.json").exists())
|
|
158
|
+
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
|
159
|
+
while (caller.isAlive && System.nanoTime() < deadline) {
|
|
160
|
+
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
|
161
|
+
caller.join(10)
|
|
162
|
+
}
|
|
163
|
+
if (caller.isAlive) {
|
|
164
|
+
caller.interrupt()
|
|
165
|
+
caller.join(1_000)
|
|
166
|
+
fail("Lifecycle did not return after Main processed teardown")
|
|
167
|
+
}
|
|
168
|
+
failure.get()?.let { throw AssertionError("Off-main lifecycle entrypoint failed", it) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun assertDisconnectedOnMain() {
|
|
172
|
+
assertEquals(false, DeviceStore.get("glasses", "connected"))
|
|
173
|
+
assertEquals(false, DeviceStore.get("glasses", "fullyBooted"))
|
|
174
|
+
assertEquals(ConnTypes.DISCONNECTED, DeviceStore.get("glasses", "connectionState"))
|
|
175
|
+
assertEquals(listOf(Looper.getMainLooper()), context.unregisterLoopers)
|
|
176
|
+
assertTrue(storeLoopers.isNotEmpty())
|
|
177
|
+
assertTrue(storeLoopers.all { it === Looper.getMainLooper() })
|
|
178
|
+
val receiptFile = File(captureDirectory, "session.json")
|
|
179
|
+
val beforeLateCallback = receiptFile.readText()
|
|
180
|
+
val receipt = JSONObject(beforeLateCallback)
|
|
181
|
+
assertFalse(receipt.getBoolean("ok"))
|
|
182
|
+
assertTrue(receipt.getString("error").contains("disconnected"))
|
|
183
|
+
writeCompleted()
|
|
184
|
+
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
|
185
|
+
assertEquals(beforeLateCallback, receiptFile.readText())
|
|
186
|
+
assertEquals(1, writes)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private fun assertRememberedDevice() {
|
|
190
|
+
val prefs = context.getSharedPreferences("NimoPrefs", Context.MODE_PRIVATE)
|
|
191
|
+
assertEquals("00:11:22:33:44:55", prefs.getString("nimo_lastDeviceAddress", null))
|
|
192
|
+
assertEquals("Nimo-test", prefs.getString("nimo_lastDeviceName", null))
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
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 unknownPeerCannotLaunchFromHandshakeOrTwsOnlyReports() {
|
|
125
|
+
canvas.offer(byteArrayOf(0, 0, 1), "initial")
|
|
126
|
+
finishHandshake(peer = null)
|
|
127
|
+
assertKeys()
|
|
128
|
+
emit(packet(6, 3, byteArrayOf(0, 1)))
|
|
129
|
+
assertNull(field("peerCompanionReady").get(nimo))
|
|
130
|
+
assertKeys()
|
|
131
|
+
emit(heartbeat(peer = 0))
|
|
132
|
+
assertEquals(false, field("peerCompanionReady").get(nimo))
|
|
133
|
+
assertKeys()
|
|
134
|
+
emit(heartbeat())
|
|
135
|
+
assertEquals(true, field("peerCompanionReady").get(nimo))
|
|
136
|
+
assertKeys(1)
|
|
137
|
+
assertTrue(failures.isEmpty())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test fun queuedOldGattAndWrongServiceCannotEstablishReadiness() {
|
|
141
|
+
canvas.offer(byteArrayOf(0, 0, 1), "initial")
|
|
142
|
+
finishHandshake(peer = null)
|
|
143
|
+
val oldGatt = currentGatt
|
|
144
|
+
queue(heartbeat(), oldGatt)
|
|
145
|
+
currentGatt = newGatt()
|
|
146
|
+
field("gatt").set(nimo, currentGatt)
|
|
147
|
+
idle() // The generation check must occur when the queued callback executes.
|
|
148
|
+
emit(heartbeat(), oldGatt)
|
|
149
|
+
emit(heartbeat(), currentGatt, characteristic(UUID.fromString("00001234-0000-1000-8000-00805f9b34fb")))
|
|
150
|
+
assertNull(field("peerCompanionReady").get(nimo))
|
|
151
|
+
assertKeys()
|
|
152
|
+
|
|
153
|
+
emit(heartbeat()) // Positive control through the actual current RX callback.
|
|
154
|
+
assertKeys(1)
|
|
155
|
+
oldGatt.close()
|
|
156
|
+
assertTrue(failures.isEmpty())
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private fun finishHandshake(peer: Boolean?) {
|
|
160
|
+
field("twsConnected").set(nimo, true)
|
|
161
|
+
field("peerCompanionReady").set(nimo, peer)
|
|
162
|
+
Nimo::class.java.getDeclaredMethod("finishHandshake").apply { isAccessible = true }.invoke(nimo)
|
|
163
|
+
idle()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private fun newGatt(): BluetoothGatt = ShadowBluetoothGatt.newInstance(
|
|
167
|
+
BluetoothAdapter.getDefaultAdapter().getRemoteDevice("00:11:22:33:44:55"),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
private fun characteristic(serviceId: UUID): BluetoothGattCharacteristic {
|
|
171
|
+
val characteristic = BluetoothGattCharacteristic(NimoBLE.CHAR_RX,
|
|
172
|
+
BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ)
|
|
173
|
+
BluetoothGattService(serviceId, BluetoothGattService.SERVICE_TYPE_PRIMARY).addCharacteristic(characteristic)
|
|
174
|
+
return characteristic
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun heartbeat(status: Int = 0, peer: Int = 1) = packet(6, 4,
|
|
178
|
+
byteArrayOf(status.toByte(), 3) + ByteArray(8) + byteArrayOf(1, peer.toByte()))
|
|
179
|
+
|
|
180
|
+
private fun packet(command: Int, key: Int, payload: ByteArray) =
|
|
181
|
+
NimoFrameCodec.encodeFrame(command, key, payload, needsAck = false)
|
|
182
|
+
|
|
183
|
+
private fun queue(packet: ByteArray, gatt: BluetoothGatt = currentGatt,
|
|
184
|
+
characteristic: BluetoothGattCharacteristic = rx) {
|
|
185
|
+
characteristic.value = packet.copyOf()
|
|
186
|
+
callback.onCharacteristicChanged(gatt, characteristic)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private fun emit(packet: ByteArray, gatt: BluetoothGatt = currentGatt,
|
|
190
|
+
characteristic: BluetoothGattCharacteristic = rx) {
|
|
191
|
+
queue(packet, gatt, characteristic)
|
|
192
|
+
idle()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private fun idle() = Shadows.shadowOf(Looper.getMainLooper()).idle()
|
|
196
|
+
private fun field(name: String) = Nimo::class.java.getDeclaredField(name).apply { isAccessible = true }
|
|
197
|
+
private fun assertKeys(vararg expected: Int) =
|
|
198
|
+
assertEquals(expected.toList(), frames.map { it[9].toInt() and 255 })
|
|
199
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"familyBaseVersion": "3.3.0",
|
|
4
|
-
"releaseIdentity": "3.3.0-dev.
|
|
5
|
-
"releaseSetId": "mentra-3.3.0-dev.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
8
|
-
"otaManifestSha256": "
|
|
4
|
+
"releaseIdentity": "3.3.0-dev.320",
|
|
5
|
+
"releaseSetId": "mentra-3.3.0-dev.320",
|
|
6
|
+
"sourceCommit": "09b74eac29a0f25515856bc31ca48fee514537e5",
|
|
7
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.320.json",
|
|
8
|
+
"otaManifestSha256": "217126614245f133053a9c340003bb4d75dc7812271f723d56ed90a855cc3464"
|
|
9
9
|
});
|
|
10
10
|
//# sourceMappingURL=releaseMetadata.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.
|
|
1
|
+
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.320\",\n \"releaseSetId\": \"mentra-3.3.0-dev.320\",\n \"sourceCommit\": \"09b74eac29a0f25515856bc31ca48fee514537e5\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.320.json\",\n \"otaManifestSha256\": \"217126614245f133053a9c340003bb4d75dc7812271f723d56ed90a855cc3464\"\n})\n"]}
|
|
@@ -38,13 +38,14 @@ Pod::Spec.new do |s|
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
# iOS frameworks required by Bluetooth SDK
|
|
41
|
-
ios_frameworks = ['AVFoundation', 'CoreBluetooth', 'UIKit', 'CoreGraphics']
|
|
41
|
+
ios_frameworks = ['AVFoundation', 'CoreBluetooth', 'UIKit', 'CoreGraphics', 'ImageIO']
|
|
42
42
|
ios_frameworks << 'Network' if include_expo_adapter
|
|
43
43
|
s.ios.frameworks = ios_frameworks
|
|
44
44
|
s.osx.frameworks = ['AVFoundation', 'CoreBluetooth', 'CoreAudio', 'AudioToolbox', 'ImageIO', 'JavaScriptCore', 'Network']
|
|
45
45
|
|
|
46
46
|
# System libraries required by MentraOS
|
|
47
|
-
s.ios.
|
|
47
|
+
s.ios.libraries = 'bz2', 'z'
|
|
48
|
+
s.osx.library = 'z'
|
|
48
49
|
|
|
49
50
|
# Vendored frameworks
|
|
50
51
|
s.ios.vendored_frameworks = 'Packages/SherpaOnnx/sherpa-onnx.xcframework'
|
|
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
|
|
|
11
11
|
static let voiceActivityDetectionEnabled = false
|
|
12
12
|
static let loudnessGateEnabled = false
|
|
13
13
|
private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
|
|
14
|
-
private static let swiftPackageSdkVersion = "3.3.0-dev.
|
|
14
|
+
private static let swiftPackageSdkVersion = "3.3.0-dev.320"
|
|
15
15
|
private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
|
|
16
16
|
|
|
17
17
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -1024,14 +1024,18 @@ struct ViewState {
|
|
|
1024
1024
|
sgc.setDashboardPosition(h, d)
|
|
1025
1025
|
}
|
|
1026
1026
|
|
|
1027
|
-
//
|
|
1027
|
+
// Preserve the connected-edge scene replay on full-frame adapters.
|
|
1028
1028
|
if shouldSendBootingMessage {
|
|
1029
|
-
Task {
|
|
1030
|
-
await sgc.sendTextWall("// MentraOS Connected")
|
|
1031
|
-
try? await Task.sleep(nanoseconds: 3_000_000_000) // 1 second
|
|
1032
|
-
sgc.clearDisplay()
|
|
1033
|
-
}
|
|
1034
1029
|
shouldSendBootingMessage = false
|
|
1030
|
+
if sgc.showConnectionConfirmation {
|
|
1031
|
+
Task {
|
|
1032
|
+
guard (self.sgc as AnyObject?) === (sgc as AnyObject) else { return }
|
|
1033
|
+
await sgc.sendTextWall("// MentraOS Connected")
|
|
1034
|
+
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
1035
|
+
guard (self.sgc as AnyObject?) === (sgc as AnyObject) else { return }
|
|
1036
|
+
sgc.clearDisplay()
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1035
1039
|
}
|
|
1036
1040
|
|
|
1037
1041
|
// Call device-specific setup handlers
|
|
@@ -1214,7 +1218,9 @@ struct ViewState {
|
|
|
1214
1218
|
self.dashboardSceneCleanupPending = false
|
|
1215
1219
|
let elementIds = Array(self.pendingDashboardSceneElementIds)
|
|
1216
1220
|
self.pendingDashboardSceneElementIds.removeAll()
|
|
1217
|
-
|
|
1221
|
+
if self.sgc?.sceneHandoffRequiresClear == true {
|
|
1222
|
+
await self.sgc?.clearSceneElements(elementIds)
|
|
1223
|
+
}
|
|
1218
1224
|
}
|
|
1219
1225
|
self.dashboardSceneCleanupTask = nil
|
|
1220
1226
|
}
|
|
@@ -1261,7 +1267,7 @@ struct ViewState {
|
|
|
1261
1267
|
if stateIndex == 1, dashboardSceneCleanupDeferred {
|
|
1262
1268
|
dashboardSceneCleanupPending = true
|
|
1263
1269
|
pendingDashboardSceneElementIds.formUnion(prevFrame.elements.map(\.id))
|
|
1264
|
-
} else if layoutType != "clear_view" {
|
|
1270
|
+
} else if layoutType != "clear_view", shouldClearSceneHandoff(stateIndex) {
|
|
1265
1271
|
let ids = prevFrame.elements.map(\.id)
|
|
1266
1272
|
Task { [weak self] in
|
|
1267
1273
|
await self?.sgc?.clearSceneElements(ids)
|
|
@@ -1355,7 +1361,7 @@ struct ViewState {
|
|
|
1355
1361
|
// on G2 - no page rebuild).
|
|
1356
1362
|
let prevLegacyType = viewStates[stateIndex].layoutType
|
|
1357
1363
|
let cleanupDeferred = stateIndex == 1 && dashboardSceneCleanupDeferred
|
|
1358
|
-
if !cleanupDeferred,
|
|
1364
|
+
if !cleanupDeferred, shouldClearSceneHandoff(stateIndex),
|
|
1359
1365
|
!prevLegacyType.isEmpty,
|
|
1360
1366
|
prevLegacyType != "clear_view",
|
|
1361
1367
|
prevLegacyType != "scene"
|
|
@@ -1371,7 +1377,7 @@ struct ViewState {
|
|
|
1371
1377
|
if stateIndex == 1, dashboardSceneCleanupDeferred {
|
|
1372
1378
|
dashboardSceneCleanupPending = true
|
|
1373
1379
|
pendingDashboardSceneElementIds.formUnion(prevFrame.elements.map(\.id))
|
|
1374
|
-
} else {
|
|
1380
|
+
} else if shouldClearSceneHandoff(stateIndex) {
|
|
1375
1381
|
let ids = prevFrame.elements.map(\.id)
|
|
1376
1382
|
Task { [weak self] in
|
|
1377
1383
|
await self?.sgc?.clearSceneElements(ids)
|
|
@@ -1395,6 +1401,13 @@ struct ViewState {
|
|
|
1395
1401
|
}
|
|
1396
1402
|
}
|
|
1397
1403
|
|
|
1404
|
+
private func shouldClearSceneHandoff(_ stateIndex: Int) -> Bool {
|
|
1405
|
+
let visibleIndex = headUp && contextualDashboard ? 1 : 0
|
|
1406
|
+
guard let sgc else { return false }
|
|
1407
|
+
return stateIndex == visibleIndex && !screenDisabled && sgc.fullyBooted
|
|
1408
|
+
&& !sgc.type.contains(DeviceTypes.SIMULATED) && sgc.sceneHandoffRequiresClear
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1398
1411
|
/// Guarded scene dispatch - mirrors sendCurrentState's send conditions.
|
|
1399
1412
|
private func dispatchSceneFrame(_ frame: SceneFrame, stateIndex: Int) {
|
|
1400
1413
|
if screenDisabled { return }
|
|
@@ -191,14 +191,25 @@ class DeviceStore {
|
|
|
191
191
|
|
|
192
192
|
// BLUETOOTH:
|
|
193
193
|
|
|
194
|
+
case ("bluetooth", "contextual_dashboard"):
|
|
195
|
+
if value is Bool {
|
|
196
|
+
Task { @MainActor in
|
|
197
|
+
if self.store.get("glasses", "headUp") as? Bool == true {
|
|
198
|
+
DeviceManager.shared.sendCurrentState()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
194
203
|
case ("bluetooth", "brightness"):
|
|
195
204
|
let b = value as? Int ?? 50
|
|
196
205
|
let auto = store.get("bluetooth", "auto_brightness") as? Bool ?? true
|
|
197
206
|
Task {
|
|
198
|
-
DeviceManager.shared.sgc
|
|
199
|
-
|
|
207
|
+
guard let device = DeviceManager.shared.sgc else { return }
|
|
208
|
+
device.setBrightness(b, autoMode: auto)
|
|
209
|
+
guard device.showBrightnessConfirmation else { return }
|
|
210
|
+
await device.sendTextWall("Set brightness to \(b)%")
|
|
200
211
|
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
|
|
201
|
-
DeviceManager.shared.sgc
|
|
212
|
+
if (DeviceManager.shared.sgc as AnyObject?) === (device as AnyObject) { device.clearDisplay() }
|
|
202
213
|
}
|
|
203
214
|
|
|
204
215
|
case ("bluetooth", "auto_brightness"):
|
|
@@ -206,13 +217,14 @@ class DeviceStore {
|
|
|
206
217
|
let auto = value as? Bool ?? true
|
|
207
218
|
let autoBrightnessChanged = (oldValue as? Bool) != auto
|
|
208
219
|
Task {
|
|
209
|
-
DeviceManager.shared.sgc
|
|
210
|
-
|
|
211
|
-
|
|
220
|
+
guard let device = DeviceManager.shared.sgc else { return }
|
|
221
|
+
device.setBrightness(b, autoMode: auto)
|
|
222
|
+
if autoBrightnessChanged, device.showBrightnessConfirmation {
|
|
223
|
+
await device.sendTextWall(
|
|
212
224
|
auto ? "Enabled auto brightness" : "Disabled auto brightness"
|
|
213
225
|
)
|
|
214
226
|
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
|
|
215
|
-
DeviceManager.shared.sgc
|
|
227
|
+
if (DeviceManager.shared.sgc as AnyObject?) === (device as AnyObject) { device.clearDisplay() }
|
|
216
228
|
}
|
|
217
229
|
}
|
|
218
230
|
|
|
@@ -3,9 +3,9 @@ import Foundation
|
|
|
3
3
|
/// Generated by release CI. Do not edit in a release checkout.
|
|
4
4
|
enum GeneratedReleaseMetadata {
|
|
5
5
|
static let familyBaseVersion = "3.3.0"
|
|
6
|
-
static let releaseIdentity = "3.3.0-dev.
|
|
7
|
-
static let releaseSetId = "mentra-3.3.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
6
|
+
static let releaseIdentity = "3.3.0-dev.320"
|
|
7
|
+
static let releaseSetId = "mentra-3.3.0-dev.320"
|
|
8
|
+
static let sourceCommit = "09b74eac29a0f25515856bc31ca48fee514537e5"
|
|
9
|
+
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.320.json"
|
|
10
|
+
static let otaManifestSha256 = "217126614245f133053a9c340003bb4d75dc7812271f723d56ed90a855cc3464"
|
|
11
11
|
}
|
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -2230,7 +2230,7 @@ class G2: NSObject, SGCManager {
|
|
|
2230
2230
|
let borderColor = borderColor ?? G2.defaultTextContainer.borderColor
|
|
2231
2231
|
let borderRadius = borderRadius ?? G2.defaultTextContainer.borderRadius
|
|
2232
2232
|
let paddingLength = paddingLength ?? G2.defaultTextContainer.paddingLength
|
|
2233
|
-
let content =
|
|
2233
|
+
let content = G2Text.containerContent(text)
|
|
2234
2234
|
|
|
2235
2235
|
// Pure state mutation: update the container's content and schedule its sends; the reconcile
|
|
2236
2236
|
// loop (`displayReconcileTask`) does the actual updateText writes. Reuse an existing container
|
|
@@ -2391,7 +2391,7 @@ class G2: NSObject, SGCManager {
|
|
|
2391
2391
|
// y is clamped first so the height term can't go negative.
|
|
2392
2392
|
let y = min(max(y, 0), 288 - G2.minTextContainerHeight)
|
|
2393
2393
|
let height = min(max(height, G2.minTextContainerHeight), 288 - y)
|
|
2394
|
-
let content =
|
|
2394
|
+
let content = G2Text.containerContent(text)
|
|
2395
2395
|
if let existingId = sceneTextByElement[elementId] {
|
|
2396
2396
|
if let i = textContainers.firstIndex(where: { $0.id == existingId }) {
|
|
2397
2397
|
let c = textContainers[i]
|