@mentra/bluetooth-sdk 3.3.0-dev.317 → 3.3.0-dev.321

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 (46) hide show
  1. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +37 -12
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -13
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3 -2
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  14. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  21. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  22. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  23. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  24. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  25. package/build/generated/releaseMetadata.js +5 -5
  26. package/build/generated/releaseMetadata.js.map +1 -1
  27. package/ios/MentraBluetoothSDK.podspec +3 -2
  28. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  29. package/ios/Source/DeviceManager.swift +23 -10
  30. package/ios/Source/DeviceStore.swift +19 -7
  31. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  32. package/ios/Source/sgcs/G2.swift +2 -2
  33. package/ios/Source/sgcs/Nimo.swift +182 -371
  34. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  35. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  36. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  37. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  38. package/ios/Source/sgcs/SGCManager.swift +16 -0
  39. package/ios/Source/utils/G2Text.swift +11 -0
  40. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  41. package/ios/Tests/G2TextTests.swift +10 -0
  42. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  43. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  44. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  45. package/package.json +2 -2
  46. 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.317",
5
- "releaseSetId": "mentra-3.3.0-dev.317",
6
- "sourceCommit": "f5be01b015898d019a200369c824eddba5f0f802",
7
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.317.json",
8
- "otaManifestSha256": "573b09239a97f0bbeb03a53b460e89a71ad62c7aad4f32dc93fd042cfb6d03d2"
4
+ "releaseIdentity": "3.3.0-dev.321",
5
+ "releaseSetId": "mentra-3.3.0-dev.321",
6
+ "sourceCommit": "75da48dec4a113644b96610acf0d92a2d1b5433c",
7
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.321.json",
8
+ "otaManifestSha256": "bc7004312a321bc7e2218304ccbf754304ce27f98f2787d5affc6d79802b253e"
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.317\",\n \"releaseSetId\": \"mentra-3.3.0-dev.317\",\n \"sourceCommit\": \"f5be01b015898d019a200369c824eddba5f0f802\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.317.json\",\n \"otaManifestSha256\": \"573b09239a97f0bbeb03a53b460e89a71ad62c7aad4f32dc93fd042cfb6d03d2\"\n})\n"]}
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.321\",\n \"releaseSetId\": \"mentra-3.3.0-dev.321\",\n \"sourceCommit\": \"75da48dec4a113644b96610acf0d92a2d1b5433c\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.321.json\",\n \"otaManifestSha256\": \"bc7004312a321bc7e2218304ccbf754304ce27f98f2787d5affc6d79802b253e\"\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.library = 'bz2'
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.317"
14
+ private static let swiftPackageSdkVersion = "3.3.0-dev.321"
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
- // Show welcome message on first connect for all display glasses
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
- await self.sgc?.clearSceneElements(elementIds)
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?.setBrightness(b, autoMode: auto)
199
- await DeviceManager.shared.sgc?.sendTextWall("Set brightness to \(b)%")
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?.clearDisplay()
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?.setBrightness(b, autoMode: auto)
210
- if autoBrightnessChanged {
211
- await DeviceManager.shared.sgc?.sendTextWall(
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?.clearDisplay()
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.317"
7
- static let releaseSetId = "mentra-3.3.0-dev.317"
8
- static let sourceCommit = "f5be01b015898d019a200369c824eddba5f0f802"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.317.json"
10
- static let otaManifestSha256 = "573b09239a97f0bbeb03a53b460e89a71ad62c7aad4f32dc93fd042cfb6d03d2"
6
+ static let releaseIdentity = "3.3.0-dev.321"
7
+ static let releaseSetId = "mentra-3.3.0-dev.321"
8
+ static let sourceCommit = "75da48dec4a113644b96610acf0d92a2d1b5433c"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.321.json"
10
+ static let otaManifestSha256 = "bc7004312a321bc7e2218304ccbf754304ce27f98f2787d5affc6d79802b253e"
11
11
  }
@@ -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 = text.isEmpty ? " " : text
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 = text.isEmpty ? " " : text
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]