@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,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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
-
|
|
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()
|