@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.
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,368 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.IntentFilter
7
+ import android.os.Build
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import android.os.SystemClock
11
+ import android.util.Log
12
+ import org.json.JSONArray
13
+ import org.json.JSONObject
14
+ import java.io.BufferedWriter
15
+ import java.io.File
16
+ import java.security.MessageDigest
17
+ import java.security.SecureRandom
18
+ import java.util.UUID
19
+
20
+ /** ADB-only, debug-build framebuffer capture on the existing owning GATT connection.
21
+ *
22
+ * No display, lifecycle, firmware, memory-address, or arbitrary-command controls are exposed.
23
+ * Captures are private device files, not application events or cloud logs. Two matching reads
24
+ * are non-atomic panel-memory evidence, not proof of light emitted by the display.
25
+ */
26
+ internal class NimoDiagnostics(
27
+ context: Context,
28
+ private val handler: Handler,
29
+ private val send: (ByteArray, () -> Unit) -> Boolean,
30
+ private val requestCanvasHold: ((() -> Unit) -> Unit) = { ready -> ready() },
31
+ private val releaseCanvasHold: (resume: Boolean) -> Unit = {},
32
+ ) {
33
+ private val context = context.applicationContext
34
+ private val random = SecureRandom()
35
+ private var generation = 0L
36
+ private var maxWriteBytes = 0
37
+ private var registered = false
38
+ private var closed = false
39
+ private var active: Capture? = null
40
+
41
+ private enum class Stage { VERSION, READINESS, PING, GATE, PAGE }
42
+ private class Step(val stage: Stage, val request: NimoFramebufferProtocol.Request? = null) {
43
+ var timeout: Runnable? = null
44
+ }
45
+ private class Capture(val mode: String, val directory: File, val generation: Long) {
46
+ val started = SystemClock.elapsedRealtime()
47
+ val startedWallTime = System.currentTimeMillis()
48
+ val wire: BufferedWriter = File(directory, "wire.jsonl").bufferedWriter()
49
+ val nonces = mutableSetOf<Long>()
50
+ val banks = mutableListOf<NimoFramebufferProtocol.BankRead>()
51
+ val bankReceipts = JSONArray()
52
+ var wireCount = 0
53
+ var version: String? = null
54
+ var readiness: List<Int>? = null
55
+ var pending: Step? = null
56
+ var deadline: Runnable? = null
57
+ var currentBank: NimoFramebufferProtocol.BankRead? = null
58
+ var probeOk = false
59
+ var verifiedBanks = 0
60
+ var sampleState: NimoFramebufferProtocol.State? = null
61
+ var readStarted: Long? = null
62
+ var readFinished: Long? = null
63
+ var holdRequested = false
64
+ var holdReleased = false
65
+ }
66
+
67
+ private val receiver = object : BroadcastReceiver() {
68
+ override fun onReceive(context: Context?, intent: Intent?) {
69
+ requireMain()
70
+ if (intent?.action != ACTION) return
71
+ val mode = intent.getStringExtra("mode") ?: "probe"
72
+ if (mode !in listOf("probe", "capture", "sample", "held-capture", "held-sample")) {
73
+ Log.w(TAG, "Rejected unsupported mode")
74
+ return
75
+ }
76
+ start(mode)
77
+ }
78
+ }
79
+
80
+ fun connected(maxWriteBytes: Int) {
81
+ requireMain()
82
+ if (closed) return
83
+ disconnected()
84
+ this.maxWriteBytes = maxWriteBytes
85
+ try {
86
+ val filter = IntentFilter(ACTION)
87
+ if (Build.VERSION.SDK_INT >= 33) {
88
+ context.registerReceiver(receiver, filter, "android.permission.DUMP", handler, Context.RECEIVER_EXPORTED)
89
+ } else {
90
+ @Suppress("DEPRECATION")
91
+ context.registerReceiver(receiver, filter, "android.permission.DUMP", handler)
92
+ }
93
+ registered = true
94
+ Log.i(TAG, "ADB diagnostic receiver ready; write capacity=$maxWriteBytes")
95
+ } catch (error: RuntimeException) {
96
+ Log.w(TAG, "Diagnostic receiver unavailable: ${error.javaClass.simpleName}")
97
+ }
98
+ }
99
+
100
+ fun disconnected() {
101
+ requireMain()
102
+ generation += 1
103
+ active?.let { finish(it, "GATT disconnected or connection generation changed", resumeHeldCanvas = false) }
104
+ maxWriteBytes = 0
105
+ if (registered) {
106
+ registered = false
107
+ try { context.unregisterReceiver(receiver) } catch (_: IllegalArgumentException) { }
108
+ }
109
+ }
110
+
111
+ fun close() {
112
+ requireMain()
113
+ disconnected()
114
+ closed = true
115
+ }
116
+
117
+ fun cancelHeldCapture(reason: String, resumeCanvas: Boolean = false) {
118
+ requireMain()
119
+ val capture = active ?: return
120
+ if (isHeld(capture.mode)) finish(capture, reason, resumeCanvas)
121
+ }
122
+
123
+ /** Called only for notifications from the owning NIMO response characteristic. */
124
+ fun onPacket(packet: ByteArray): Boolean {
125
+ requireMain()
126
+ val capture = active ?: return false
127
+ if (packet.size < 10 || packet[8].toInt() != 2) return false
128
+ val key = packet[9].toInt() and 255
129
+ if (key !in listOf(0x0B, 0x19, 0x15)) return false
130
+ try {
131
+ recordWire(capture, "rx", packet)
132
+ val step = capture.pending
133
+ when (key) {
134
+ 0x0B -> {
135
+ val payload = NimoFramebufferProtocol.basicPayload(packet, key)
136
+ capture.version = if (payload.isNotEmpty()) payload.copyOfRange(1, payload.size).toString(Charsets.US_ASCII) else ""
137
+ NimoFramebufferProtocol.version(packet)
138
+ if (step?.stage == Stage.VERSION) {
139
+ completeStep(capture, step)
140
+ request(capture, Step(Stage.READINESS))
141
+ }
142
+ }
143
+ 0x19 -> {
144
+ capture.readiness = NimoFramebufferProtocol.readiness(packet)
145
+ val state = capture.readiness!!
146
+ Log.i(TAG, "Readiness status=${state[0]} ready=${state[1]} tws=${state[2]} peerGatt=${state[3]}")
147
+ if (step?.stage == Stage.READINESS) {
148
+ completeStep(capture, step)
149
+ request(capture, Step(Stage.PING, NimoFramebufferProtocol.Request(nonce(capture), 0, 0, 0)))
150
+ }
151
+ }
152
+ 0x15 -> {
153
+ require(step != null && step.request != null) { "Unexpected FBP1 response without a pending request" }
154
+ val reply = NimoFramebufferProtocol.reply(packet, step.request)
155
+ completeStep(capture, step)
156
+ when (step.stage) {
157
+ Stage.PING -> request(capture, Step(Stage.GATE, NimoFramebufferProtocol.Request(nonce(capture), 1, 0, 0)))
158
+ Stage.GATE -> {
159
+ // A complete 275-byte response must succeed before any bulk read is attempted.
160
+ capture.probeOk = true
161
+ if (isSample(capture.mode)) capture.sampleState = reply.after
162
+ Log.i(TAG, "FBP1 identity and full-page gate verified; mode=${capture.mode}")
163
+ if (capture.mode == "probe") finish(capture, null) else nextBank(capture)
164
+ }
165
+ Stage.PAGE -> acceptPage(capture, reply)
166
+ else -> throw IllegalArgumentException("FBP1 response arrived in an invalid phase")
167
+ }
168
+ }
169
+ }
170
+ } catch (error: Exception) {
171
+ finish(capture, "${error.javaClass.simpleName}: ${error.message?.take(160)}")
172
+ }
173
+ // Basic responses remain visible to the production handshake. Private FBP1 never enters
174
+ // the legacy assembler, whose response length convention is different.
175
+ return key == 0x15
176
+ }
177
+
178
+ private fun start(mode: String) {
179
+ if (closed || !registered) return
180
+ if (active != null) { Log.w(TAG, "Capture already running; ignored duplicate request"); return }
181
+ val capture = try {
182
+ val root = File(context.filesDir, "nimo-framebuffer")
183
+ require(root.isDirectory || root.mkdirs()) { "Cannot create private capture directory" }
184
+ val directory = File(root, "${System.currentTimeMillis()}-${UUID.randomUUID().toString().take(8)}")
185
+ require(directory.mkdir()) { "Cannot create unique capture directory" }
186
+ Capture(mode, directory, generation)
187
+ } catch (error: Exception) {
188
+ Log.e(TAG, "Cannot begin private capture: ${error.javaClass.simpleName}")
189
+ return
190
+ }
191
+ active = capture
192
+ Log.i(TAG, "Starting $mode; files=${capture.directory.absolutePath}")
193
+ if (maxWriteBytes < 24) { finish(capture, "Write capacity below 24-byte diagnostic request"); return }
194
+ val budgetMs = when (mode) {
195
+ "held-sample" -> 15_000L
196
+ "held-capture" -> 55_000L
197
+ else -> 600_000L
198
+ }
199
+ val budgetDescription = when (mode) {
200
+ "held-sample" -> "15-second budget"
201
+ "held-capture" -> "55-second budget"
202
+ else -> "10-minute budget"
203
+ }
204
+ val deadline = Runnable {
205
+ if (active === capture) finish(capture, "Capture exceeded $budgetDescription")
206
+ }
207
+ capture.deadline = deadline
208
+ handler.postDelayed(deadline, budgetMs)
209
+ if (isHeld(mode)) {
210
+ capture.holdRequested = true
211
+ requestCanvasHold {
212
+ requireMain()
213
+ if (active === capture && capture.generation == generation) request(capture, Step(Stage.VERSION))
214
+ }
215
+ } else {
216
+ request(capture, Step(Stage.VERSION))
217
+ }
218
+ }
219
+
220
+ private fun request(capture: Capture, step: Step) {
221
+ if (active !== capture || capture.generation != generation) return
222
+ require(capture.pending == null) { "Only one diagnostic request may be in flight" }
223
+ val frame = when (step.stage) {
224
+ Stage.VERSION -> NimoFramebufferProtocol.queryFrame(0x0B)
225
+ Stage.READINESS -> NimoFramebufferProtocol.queryFrame(0x19)
226
+ else -> NimoFramebufferProtocol.requestFrame(requireNotNull(step.request))
227
+ }
228
+ capture.pending = step
229
+ try {
230
+ recordWire(capture, "tx", frame)
231
+ val accepted = send(frame) {
232
+ requireMain()
233
+ // A response may beat Android's write callback. Never arm an old step's timeout.
234
+ if (active === capture && capture.generation == generation && capture.pending === step) {
235
+ val timeout = Runnable {
236
+ if (active === capture && capture.pending === step) finish(capture, "${step.stage} response timeout")
237
+ }
238
+ step.timeout = timeout
239
+ handler.postDelayed(timeout, 3_000L)
240
+ }
241
+ }
242
+ if (!accepted && active === capture) finish(capture, "Owning GATT queue rejected diagnostic request")
243
+ } catch (error: Exception) {
244
+ finish(capture, "Diagnostic send failed: ${error.javaClass.simpleName}")
245
+ }
246
+ }
247
+
248
+ private fun completeStep(capture: Capture, step: Step) {
249
+ step.timeout?.let(handler::removeCallbacks)
250
+ capture.pending = null
251
+ }
252
+
253
+ private fun nextBank(capture: Capture) {
254
+ if (isSample(capture.mode) && capture.banks.size == 1) { finish(capture, null); return }
255
+ if (capture.banks.size == 4) { finish(capture, null); return }
256
+ val bank = if (isSample(capture.mode)) requireNotNull(capture.sampleState).prepared else capture.banks.size / 2
257
+ val read = NimoFramebufferProtocol.BankRead(nonce(capture), bank)
258
+ capture.currentBank = read
259
+ capture.readStarted = SystemClock.elapsedRealtime() - capture.started
260
+ request(capture, Step(Stage.PAGE, NimoFramebufferProtocol.Request(read.nonce, 1, bank, 0)))
261
+ }
262
+
263
+ private fun acceptPage(capture: Capture, reply: NimoFramebufferProtocol.Reply) {
264
+ val read = requireNotNull(capture.currentBank)
265
+ if (isSample(capture.mode)) {
266
+ require(reply.before == capture.sampleState) { "Sample metadata differs from page gate" }
267
+ }
268
+ read.add(reply)
269
+ if (read.pageCount % 63 == 0) Log.i(TAG, "bank=${read.bank} pass=${capture.banks.size % 2 + 1} pages=${read.pageCount}/315")
270
+ if (read.pageCount < NimoFramebufferProtocol.PAGE_COUNT) {
271
+ request(capture, Step(Stage.PAGE, NimoFramebufferProtocol.Request(read.nonce, 1, read.bank, read.pageCount)))
272
+ return
273
+ }
274
+ val pass = capture.banks.size % 2 + 1
275
+ val filename = "bank${read.bank}-pass$pass.bin"
276
+ val bytes = read.bytes()
277
+ capture.readFinished = SystemClock.elapsedRealtime() - capture.started
278
+ File(capture.directory, filename).writeBytes(bytes)
279
+ capture.bankReceipts.put(JSONObject().put("bank", read.bank).put("pass", pass).put("nonce", read.nonce)
280
+ .put("file", filename).put("page_count", read.pageCount).put("sha256", sha256(bytes))
281
+ .put("metadata", metadata(requireNotNull(read.metadata))))
282
+ capture.banks.add(read)
283
+ capture.currentBank = null
284
+ if (pass == 2) {
285
+ NimoFramebufferProtocol.verifyPair(capture.banks[capture.banks.size - 2], read)
286
+ capture.verifiedBanks += 1
287
+ Log.i(TAG, "Static pair verified for bank=${read.bank}; non-atomic panel memory only")
288
+ }
289
+ nextBank(capture)
290
+ }
291
+
292
+ private fun finish(capture: Capture, error: String?, resumeHeldCanvas: Boolean = true) {
293
+ if (active !== capture) return
294
+ active = null
295
+ capture.deadline?.let(handler::removeCallbacks)
296
+ capture.pending?.timeout?.let(handler::removeCallbacks)
297
+ capture.pending = null
298
+ if (capture.holdRequested && !capture.holdReleased) {
299
+ capture.holdReleased = true
300
+ releaseCanvasHold(resumeHeldCanvas)
301
+ }
302
+ var failure = error
303
+ try { capture.wire.close() } catch (_: Exception) { failure = failure ?: "Wire evidence could not be flushed" }
304
+ val complete = failure == null && !isSample(capture.mode) && capture.mode != "probe" &&
305
+ capture.verifiedBanks == 2 && capture.banks.size == 4
306
+ val sampleComplete = failure == null && isSample(capture.mode) && capture.banks.size == 1
307
+ val receipt = JSONObject().put("mode", capture.mode).put("ok", complete)
308
+ .put("sample_ok", sampleComplete)
309
+ .put("probe_ok", capture.probeOk && failure == null).put("error", failure ?: JSONObject.NULL)
310
+ .put("started_at_unix_ms", capture.startedWallTime).put("elapsed_ms", SystemClock.elapsedRealtime() - capture.started)
311
+ .put("runtime_version", capture.version ?: JSONObject.NULL)
312
+ .put("declared_installed_artifact_sha256", NimoFramebufferProtocol.ARTIFACT_SHA256)
313
+ .put("artifact_provenance", "Previously installed local diagnostic artifact; runtime SHA-256 is not measured")
314
+ .put("runtime_digest_measured", false)
315
+ .put("evidence_kind", if (isSample(capture.mode)) "non_atomic_single_pass" else "non_atomic_static_scene")
316
+ .put("pixel_stability_verified", complete)
317
+ .put("atomic", false).put("optical", false).put("optical_output_verified", false)
318
+ .put("notification_source", JSONObject().put("service", "7033").put("characteristic", "2022")
319
+ .put("transport", "Mentra owning Android GATT"))
320
+ .put("write_capacity", maxWriteBytes).put("width", NimoFramebufferProtocol.WIDTH)
321
+ .put("height", NimoFramebufferProtocol.HEIGHT).put("format", "packed4_high_nibble_first")
322
+ .put("wire_file", "wire.jsonl").put("wire_events", capture.wireCount).put("banks", capture.bankReceipts)
323
+ .put("static_pairs_verified", capture.verifiedBanks)
324
+ if (isSample(capture.mode)) {
325
+ receipt.put("selection_policy", "prepared_at_page_gate")
326
+ .put("selected_bank", capture.sampleState?.prepared ?: JSONObject.NULL)
327
+ .put("selection_metadata", capture.sampleState?.let(::metadata) ?: JSONObject.NULL)
328
+ .put("read_started_elapsed_ms", capture.readStarted ?: JSONObject.NULL)
329
+ .put("read_finished_elapsed_ms", capture.readFinished ?: JSONObject.NULL)
330
+ }
331
+ capture.readiness?.let { state ->
332
+ receipt.put("readiness", JSONObject().put("status", state[0]).put("ready", state[1])
333
+ .put("tws", state[2]).put("peer_gatt", state[3]))
334
+ }
335
+ try {
336
+ File(capture.directory, "session.json").writeText(receipt.toString(2) + "\n")
337
+ Log.i(TAG, "Finished mode=${capture.mode} ok=$complete sample_ok=$sampleComplete probe_ok=${capture.probeOk && failure == null} error=${failure ?: "none"}; files=${capture.directory.absolutePath}")
338
+ } catch (_: Exception) {
339
+ Log.e(TAG, "Capture receipt could not be written; do not treat this capture as verified")
340
+ }
341
+ }
342
+
343
+ private fun nonce(capture: Capture): Long {
344
+ var value: Long
345
+ do { value = random.nextInt().toLong() and 0xFFFFFFFFL } while (!capture.nonces.add(value))
346
+ return value
347
+ }
348
+
349
+ private fun recordWire(capture: Capture, direction: String, packet: ByteArray) {
350
+ require(packet.size <= 512 && capture.wireCount < 2700) { "Diagnostic wire evidence exceeded its bound" }
351
+ capture.wire.write(JSONObject().put("direction", direction).put("elapsed_ms", SystemClock.elapsedRealtime() - capture.started)
352
+ .put("hex", NimoFramebufferProtocol.hex(packet)).toString())
353
+ capture.wire.newLine()
354
+ capture.wireCount += 1
355
+ }
356
+
357
+ private fun metadata(state: NimoFramebufferProtocol.State) = JSONObject().put("draw", state.draw)
358
+ .put("prepared", state.prepared).put("dirty", state.dirty).put("pending", state.pending)
359
+ private fun sha256(bytes: ByteArray) = NimoFramebufferProtocol.hex(MessageDigest.getInstance("SHA-256").digest(bytes))
360
+ private fun requireMain() { check(Looper.myLooper() == handler.looper) { "NIMO diagnostics must run on its owning handler" } }
361
+ private fun isHeld(mode: String) = mode == "held-capture" || mode == "held-sample"
362
+ private fun isSample(mode: String) = mode == "sample" || mode == "held-sample"
363
+
364
+ companion object {
365
+ private const val TAG = "NimoFramebuffer"
366
+ private const val ACTION = "com.mentra.NIMO_FRAMEBUFFER_CAPTURE"
367
+ }
368
+ }
@@ -0,0 +1,156 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ /** Private FBP1 protocol for one pinned diagnostic build, never a general memory reader. */
4
+ internal object NimoFramebufferProtocol {
5
+ const val EXPECTED_VERSION = "FW-VERSION-v0.1.1.1-20260827164351-537cf1-dirty-Debug"
6
+ const val ARTIFACT_SHA256 = "4de441f887e85b1f8194881695b96c16e8018fbdc19d4edc9f5b5d4210e5aa83"
7
+ const val WIDTH = 540
8
+ const val HEIGHT = 280
9
+ const val PAGE_BYTES = 240
10
+ const val PAGE_COUNT = 315
11
+ const val BANK_BYTES = PAGE_BYTES * PAGE_COUNT
12
+ private val MAGIC = "FBP1".toByteArray(Charsets.US_ASCII)
13
+ private val BUILD_TAG = "537cf1p1".toByteArray(Charsets.US_ASCII)
14
+
15
+ data class Request(val nonce: Long, val op: Int, val bank: Int, val page: Int)
16
+ data class State(val draw: Int, val prepared: Int, val dirty: Int, val pending: Int)
17
+ data class Reply(val nonce: Long, val op: Int, val bank: Int, val page: Int,
18
+ val before: State, val after: State, val data: ByteArray)
19
+
20
+ fun hex(bytes: ByteArray): String {
21
+ val digits = "0123456789abcdef"
22
+ val chars = CharArray(bytes.size * 2)
23
+ bytes.forEachIndexed { index, byte ->
24
+ val value = byte.toInt() and 255
25
+ chars[index * 2] = digits[value ushr 4]
26
+ chars[index * 2 + 1] = digits[value and 15]
27
+ }
28
+ return chars.concatToString()
29
+ }
30
+
31
+ fun queryFrame(key: Int): ByteArray {
32
+ require(key == 0x0B || key == 0x19) { "Only version and readiness queries are permitted" }
33
+ return frame(key, byteArrayOf())
34
+ }
35
+
36
+ fun requestFrame(request: Request): ByteArray {
37
+ validate(request)
38
+ val nonce = ByteArray(4) { (request.nonce shr (8 * it)).toByte() }
39
+ return frame(0x15, MAGIC + nonce + byteArrayOf(request.op.toByte(), request.bank.toByte(),
40
+ request.page.toByte(), (request.page shr 8).toByte()))
41
+ }
42
+
43
+ fun version(packet: ByteArray): String {
44
+ val payload = basicPayload(packet, 0x0B)
45
+ require(payload.isNotEmpty() && payload[0] == 0.toByte()) { "Version query rejected" }
46
+ val expected = EXPECTED_VERSION.toByteArray(Charsets.US_ASCII)
47
+ require(payload.copyOfRange(1, payload.size).contentEquals(expected)) { "Unexpected firmware identity" }
48
+ return EXPECTED_VERSION
49
+ }
50
+
51
+ /** Preserve status and all three readiness fields, including a not-ready result. */
52
+ fun readiness(packet: ByteArray): List<Int> {
53
+ val payload = basicPayload(packet, 0x19)
54
+ require(payload.size == 4) { "Readiness response must contain status, ready, TWS, and peer GATT" }
55
+ return payload.map { it.toInt() and 255 }
56
+ }
57
+
58
+ fun basicPayload(packet: ByteArray, key: Int): ByteArray {
59
+ require(key == 0x0B || key == 0x19) { "Unexpected basic query" }
60
+ return payload(packet, key, 128)
61
+ }
62
+
63
+ fun reply(packet: ByteArray, expected: Request): Reply {
64
+ validate(expected)
65
+ val body = payload(packet, 0x15, 23 + PAGE_BYTES)
66
+ require(body.size >= 23) { "Short FBP1 response" }
67
+ require(body.copyOfRange(1, 5).contentEquals(MAGIC)) { "FBP1 response magic mismatch" }
68
+ require(word(body, 21) == body.size - 23) { "FBP1 data length mismatch" }
69
+ val status = unsigned(body, 0)
70
+ require(status in listOf(0, 6, 7)) { "Unknown FBP1 response status $status" }
71
+ if (status != 0) {
72
+ require(body.size == 23) { "Rejected FBP1 response contains data" }
73
+ throw IllegalArgumentException("FBP1 request rejected with status $status")
74
+ }
75
+ val nonce = (0..3).fold(0L) { value, i -> value or (unsigned(body, 5 + i).toLong() shl (8 * i)) }
76
+ val actual = Request(nonce, unsigned(body, 9), unsigned(body, 10), word(body, 11))
77
+ require(actual == expected) { "FBP1 response correlation mismatch" }
78
+ val before = state(body, 13)
79
+ val after = state(body, 17)
80
+ require(before == after) { "Framebuffer metadata changed during page copy" }
81
+ val data = body.copyOfRange(23, body.size)
82
+ if (actual.op == 0) require(data.contentEquals(BUILD_TAG)) { "Unexpected FBP1 build tag" }
83
+ else require(data.size == PAGE_BYTES) { "Framebuffer page must contain $PAGE_BYTES bytes" }
84
+ return Reply(nonce, actual.op, actual.bank, actual.page, before, after, data)
85
+ }
86
+
87
+ /** Sequential, single-pass assembler. Any rejected input permanently invalidates the read. */
88
+ class BankRead(val nonce: Long, val bank: Int) {
89
+ var pageCount = 0
90
+ private set
91
+ var metadata: State? = null
92
+ private set
93
+ private var failed = false
94
+ private val data = ByteArray(BANK_BYTES)
95
+
96
+ init { validate(Request(nonce, 1, bank, 0)) }
97
+
98
+ fun add(reply: Reply) {
99
+ require(!failed) { "Bank read was invalidated" }
100
+ try {
101
+ require(pageCount < PAGE_COUNT && reply.page == pageCount) { "Duplicate or out-of-order framebuffer page" }
102
+ require(reply.nonce == nonce && reply.bank == bank && reply.op == 1) { "Bank read correlation mismatch" }
103
+ require(reply.data.size == PAGE_BYTES) { "Invalid framebuffer page length" }
104
+ require(reply.before == reply.after) { "Metadata changed during page copy" }
105
+ require(metadata == null || metadata == reply.before) { "Metadata changed between pages" }
106
+ metadata = reply.before
107
+ reply.data.copyInto(data, pageCount * PAGE_BYTES)
108
+ pageCount += 1
109
+ } catch (error: IllegalArgumentException) {
110
+ failed = true
111
+ throw error
112
+ }
113
+ }
114
+
115
+ fun bytes(): ByteArray {
116
+ require(!failed && pageCount == PAGE_COUNT) { "Framebuffer bank is incomplete or invalidated" }
117
+ return data.copyOf()
118
+ }
119
+ }
120
+
121
+ fun verifyPair(first: BankRead, second: BankRead) {
122
+ require(first.nonce != second.nonce) { "Static verification requires independent nonces" }
123
+ require(first.bank == second.bank && first.metadata == second.metadata) { "Static pair metadata mismatch" }
124
+ require(first.bytes().contentEquals(second.bytes())) { "Framebuffer pixels changed between passes" }
125
+ }
126
+
127
+ private fun validate(request: Request) {
128
+ require(request.nonce in 0..0xFFFFFFFFL && request.op in 0..1 && request.bank in 0..1 &&
129
+ request.page in 0 until PAGE_COUNT && (request.op != 0 || request.page == 0)) { "Invalid FBP1 request" }
130
+ }
131
+
132
+ private fun state(body: ByteArray, offset: Int): State {
133
+ val state = State(unsigned(body, offset), unsigned(body, offset + 1), unsigned(body, offset + 2), unsigned(body, offset + 3))
134
+ require(state.draw in 0..1 && state.prepared in 0..1) { "Invalid framebuffer selectors" }
135
+ return state
136
+ }
137
+
138
+ private fun payload(packet: ByteArray, key: Int, maximum: Int): ByteArray {
139
+ require(packet.size in 12..(12 + maximum)) { "Invalid frame size" }
140
+ require(unsigned(packet, 0) == 0xBF && packet[1] == 0.toByte()) { "Expected BF00 frame" }
141
+ require(word(packet, 6) == 0) { "Fragmented framebuffer responses are not supported" }
142
+ require(word(packet, 2) == packet.size - 8 && word(packet, 10) == packet.size - 12) { "Frame length mismatch" }
143
+ require(unsigned(packet, 8) == 2 && unsigned(packet, 9) == key) { "Frame command mismatch" }
144
+ require(word(packet, 4) == NimoCanvasCodec.crc16(packet.copyOfRange(8, packet.size))) { "Frame CRC mismatch" }
145
+ return packet.copyOfRange(12, packet.size)
146
+ }
147
+
148
+ private fun frame(key: Int, payload: ByteArray): ByteArray {
149
+ val body = byteArrayOf(2, key.toByte(), payload.size.toByte(), (payload.size shr 8).toByte()) + payload
150
+ val crc = NimoCanvasCodec.crc16(body)
151
+ return byteArrayOf(0xBF.toByte(), 0, body.size.toByte(), (body.size shr 8).toByte(),
152
+ crc.toByte(), (crc shr 8).toByte(), 0, 0) + body
153
+ }
154
+ private fun unsigned(bytes: ByteArray, index: Int) = bytes[index].toInt() and 255
155
+ private fun word(bytes: ByteArray, index: Int) = unsigned(bytes, index) or (unsigned(bytes, index + 1) shl 8)
156
+ }
@@ -45,7 +45,8 @@ import java.util.concurrent.Executors
45
45
  import java.util.concurrent.TimeUnit
46
46
  import kotlin.jvm.JvmStatic
47
47
 
48
- class DeviceManager {
48
+ class DeviceManager internal constructor(initializeHardware: Boolean) {
49
+ constructor() : this(initializeHardware = true)
49
50
  companion object {
50
51
 
51
52
  @Volatile
@@ -79,7 +80,8 @@ class DeviceManager {
79
80
  // MARK: - End Unique
80
81
 
81
82
  // MARK: - Properties
82
- var sgc: SGCManager? = null
83
+ // Read by the welcome executor as well as device lifecycle handlers.
84
+ @Volatile var sgc: SGCManager? = null
83
85
  var controller: ControllerManager? = null
84
86
 
85
87
  // settings:
@@ -284,6 +286,12 @@ class DeviceManager {
284
286
  init {
285
287
  Bridge.log("DeviceManager: init()")
286
288
  initializeViewStates()
289
+ if (initializeHardware) initializeHardwareServices()
290
+ }
291
+
292
+ // Keep host routing usable without starting audio codecs, receivers or services.
293
+ // The public constructor always initializes the hardware environment.
294
+ private fun initializeHardwareServices() {
287
295
  startForegroundService()
288
296
  // setupPermissionMonitoring()
289
297
  setupBluetoothStateMonitoring()
@@ -1518,13 +1526,18 @@ class DeviceManager {
1518
1526
  2000
1519
1527
  )
1520
1528
 
1521
- // Show welcome message on first connect for all display glasses
1529
+ // A full-canvas device can opt out so this temporary message does not
1530
+ // erase the host's connected-edge scene replay.
1522
1531
  if (shouldSendBootingMessage) {
1523
1532
  shouldSendBootingMessage = false
1524
- executor.execute {
1525
- sgc?.sendTextWall("// MentraOS Connected")
1526
- Thread.sleep(3000)
1527
- sgc?.clearDisplay()
1533
+ val device = sgc
1534
+ if (device?.showConnectionConfirmation == true) {
1535
+ executor.execute {
1536
+ if (sgc !== device) return@execute
1537
+ device.sendTextWall("// MentraOS Connected")
1538
+ Thread.sleep(3000)
1539
+ if (sgc === device) device.clearDisplay()
1540
+ }
1528
1541
  }
1529
1542
  }
1530
1543
 
@@ -1677,7 +1690,7 @@ class DeviceManager {
1677
1690
  dashboardSceneCleanupPending = false
1678
1691
  val elementIds = pendingDashboardSceneElementIds.toList()
1679
1692
  pendingDashboardSceneElementIds.clear()
1680
- if (elementIds.isNotEmpty()) {
1693
+ if (elementIds.isNotEmpty() && sgc?.sceneHandoffRequiresClear == true) {
1681
1694
  sgc?.clearSceneElements(elementIds)
1682
1695
  }
1683
1696
  }
@@ -1714,7 +1727,7 @@ class DeviceManager {
1714
1727
  sceneStates[stateIndex] = null
1715
1728
  if (stateIndex == 1 && dashboardSceneCleanupPending) {
1716
1729
  pendingDashboardSceneElementIds.addAll(prevFrame.elements.map { it.id })
1717
- } else if (layoutType != "clear_view") {
1730
+ } else if (layoutType != "clear_view" && shouldClearSceneHandoff(stateIndex)) {
1718
1731
  sgc?.clearSceneElements(prevFrame.elements.map { it.id })
1719
1732
  }
1720
1733
  }
@@ -1781,7 +1794,7 @@ class DeviceManager {
1781
1794
  val prevLegacyType = viewStates[stateIndex].layoutType
1782
1795
  val cleanupDeferred = stateIndex == 1 && dashboardSceneCleanupPending
1783
1796
  if (
1784
- !cleanupDeferred &&
1797
+ !cleanupDeferred && shouldClearSceneHandoff(stateIndex) &&
1785
1798
  prevLegacyType.isNotEmpty() &&
1786
1799
  prevLegacyType != "clear_view" &&
1787
1800
  prevLegacyType != "scene"
@@ -1797,7 +1810,7 @@ class DeviceManager {
1797
1810
  // blank.
1798
1811
  if (stateIndex == 1 && dashboardSceneCleanupPending) {
1799
1812
  pendingDashboardSceneElementIds.addAll(prevFrame.elements.map { it.id })
1800
- } else {
1813
+ } else if (shouldClearSceneHandoff(stateIndex)) {
1801
1814
  sgc?.clearSceneElements(prevFrame.elements.map { it.id })
1802
1815
  }
1803
1816
  frame = frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
@@ -1816,6 +1829,15 @@ class DeviceManager {
1816
1829
  }
1817
1830
  }
1818
1831
 
1832
+ // Hidden-view updates only change their replay slot. They must never clear
1833
+ // the visible view, and full-frame adapters need no separate handoff sweep.
1834
+ private fun shouldClearSceneHandoff(stateIndex: Int): Boolean {
1835
+ val visibleIndex = if (headUp && contextualDashboard) 1 else 0
1836
+ val device = sgc ?: return false
1837
+ return stateIndex == visibleIndex && !screenDisabled && device.fullyBooted &&
1838
+ !device.type.contains(DeviceTypes.SIMULATED) && device.sceneHandoffRequiresClear
1839
+ }
1840
+
1819
1841
  /** Guarded scene dispatch — mirrors sendCurrentState's send conditions. */
1820
1842
  private fun dispatchSceneFrame(stateIndex: Int, frame: SceneFrame) {
1821
1843
  if (screenDisabled) return
@@ -2390,7 +2412,10 @@ class DeviceManager {
2390
2412
 
2391
2413
  fun disconnect(clearPendingIdentity: Boolean = true) {
2392
2414
  sgc?.clearDisplay()
2393
- sgc?.disconnect()
2415
+ // NIMO owns a background canvas encoder and this path discards its instance.
2416
+ // A link-level reconnect keeps the instance (and encoder) through disconnect().
2417
+ val device = sgc
2418
+ if (device is Nimo) device.cleanup() else device?.disconnect()
2394
2419
  sgc = null // Clear the SGC reference after disconnect
2395
2420
  resetSystemTimeSync()
2396
2421
  resetMicHealth()