@mentra/bluetooth-sdk 3.2.1-beta.315 → 3.2.1-beta.354

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 (83) hide show
  1. package/README.md +1 -1
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +4 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +67 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  24. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  34. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  35. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  36. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  37. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  38. package/build/BluetoothSdk.types.d.ts +11 -0
  39. package/build/BluetoothSdk.types.d.ts.map +1 -1
  40. package/build/BluetoothSdk.types.js.map +1 -1
  41. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  42. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  43. package/build/_private/BluetoothSdkModule.js +3 -0
  44. package/build/_private/BluetoothSdkModule.js.map +1 -1
  45. package/build/generated/changelogCatalog.d.ts +1 -1
  46. package/build/generated/changelogCatalog.js +1 -1
  47. package/build/generated/changelogCatalog.js.map +1 -1
  48. package/build/generated/releaseMetadata.js +5 -5
  49. package/build/generated/releaseMetadata.js.map +1 -1
  50. package/build/index.d.ts.map +1 -1
  51. package/build/index.js +1 -0
  52. package/build/index.js.map +1 -1
  53. package/ios/BluetoothSdkModule.swift +5 -0
  54. package/ios/MentraBluetoothSDK.podspec +3 -2
  55. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  56. package/ios/Source/Bridge.swift +15 -2
  57. package/ios/Source/DeviceManager.swift +23 -10
  58. package/ios/Source/DeviceStore.swift +26 -7
  59. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  60. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  61. package/ios/Source/MentraBluetoothSDK.swift +4 -0
  62. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  63. package/ios/Source/sgcs/G2.swift +2 -2
  64. package/ios/Source/sgcs/MentraLive.swift +66 -3
  65. package/ios/Source/sgcs/Nimo.swift +182 -371
  66. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  67. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  68. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  69. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  70. package/ios/Source/sgcs/SGCManager.swift +24 -0
  71. package/ios/Source/utils/G2Text.swift +11 -0
  72. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  73. package/ios/Tests/G2TextTests.swift +10 -0
  74. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  75. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  76. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  77. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  78. package/package.json +2 -2
  79. package/src/BluetoothSdk.types.ts +11 -0
  80. package/src/_private/BluetoothSdkModule.ts +6 -0
  81. package/src/generated/changelogCatalog.ts +1 -1
  82. package/src/generated/releaseMetadata.ts +5 -5
  83. package/src/index.ts +1 -0
package/README.md CHANGED
@@ -507,7 +507,7 @@ Disabling restores normal hotspot-only behavior: the server rebinds to an active
507
507
  Both the native SDK and ASG client need this command; older glasses firmware times out without claiming success. The client app must have local-network access and permit cleartext HTTP to the local device under its platform/MDM policy.
508
508
 
509
509
  WiFi, hotspot, and version-info commands resolve from the ASG response path, not local dispatch:
510
- `requestWifiScan()` resolves from the ASG `wifi_scan_result` completion response with the updated scan list, including `[]` when no networks are found. Intermediate `wifi_scan_result` events can arrive with `scanComplete: false` while the glasses stream discovered networks; the final event uses `scanComplete: true`. If older glasses stream non-empty scan results but never send the completion event, the request resolves with the accumulated scan list when the request times out. `sendWifiCredentials()` resolves when the requested SSID is connected, `forgetWifiNetwork()` returns a semantic `WifiForgetResult`, `getSavedWifiNetworks()` returns a semantic `SavedWifiNetworksResult`, `setHotspotState()` resolves when the requested hotspot state is reported, and `requestVersionInfo()` waits for all chunks declared by the correlated response's `chunkCount`, `chunkIndex`, and `final` metadata. Legacy single-message responses complete immediately; legacy chunked responses complete on `version_info_3` after `version_info_1`. Missing final chunks time out rather than returning partial data after a quiet period.
510
+ `requestWifiScan()` resolves from the ASG `wifi_scan_result` completion response with the updated scan list, including `[]` when no networks are found. Intermediate `wifi_scan_result` events can arrive with `scanComplete: false` while the glasses stream discovered networks; the final event uses `scanComplete: true`. If older glasses stream non-empty scan results but never send the completion event, the request resolves with the accumulated scan list when the request times out. `sendWifiCredentials()` resolves when the requested SSID is connected, `forgetWifiNetwork()` returns a semantic `WifiForgetResult`, `getSavedWifiNetworks()` returns a semantic `SavedWifiNetworksResult`, `setHotspotState()` resolves when the requested hotspot state is reported, and `requestVersionInfo()` waits for all chunks declared by the correlated response's `chunkCount`, `chunkIndex`, and `final` metadata. Legacy single-message responses complete immediately. Factory ASG27 declares build 27 in `version_info_1` and completes on its final `version_info_2` carrying the OTA URL; other legacy chunked responses complete on `version_info_3` after `version_info_1`. Missing final chunks time out rather than returning partial data after a quiet period.
511
511
 
512
512
  Current Mentra Live builds advertise `wifiForgetResultVersion` and `savedWifiNetworksVersion` in `version_info_1`. Native queues calls while those capabilities are unknown, sharing one 15-second deadline for negotiation and response. A discovery timeout rejects and never guesses that the glasses are legacy. Only version 1 is supported: modern commands carry `protocolVersion: 1`, a nonempty request ID, and the glasses process session ID; legacy forget commands omit all three fields. Unknown or malformed versions fail closed. Correlated results must match the complete tuple and (for forget) exact SSID; unrelated `wifi_status` events never settle a modern request. Session changes and disconnects reject pending WiFi work instead of allowing stale responses to cross sessions.
513
513
 
@@ -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
+ }
@@ -4,4 +4,7 @@ package com.mentra.bluetoothsdk
4
4
  object BluetoothSdkDefaults {
5
5
  const val VOICE_ACTIVITY_DETECTION_ENABLED = false
6
6
  const val LOUDNESS_GATE_ENABLED = false
7
+
8
+ /** Off until someone turns it on in Super Mode. Re-pushed on connect. */
9
+ const val AUTO_POWER_OFF_ENABLED = false
7
10
  }
@@ -676,6 +676,10 @@ class BluetoothSdkModule : Module() {
676
676
  sdk?.setLoudnessGateEnabled(enabled)
677
677
  }
678
678
 
679
+ SdkAsyncFunction("setAutoPowerOffEnabled") { enabled: Boolean ->
680
+ sdk?.setAutoPowerOffEnabled(enabled)
681
+ }
682
+
679
683
  @Suppress("DEPRECATION")
680
684
  SdkCoroutineFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
681
685
  requireSdk().setPhotoCaptureDefaults(PhotoCaptureDefaults.fromMap(params)).values
@@ -402,9 +402,10 @@ public class Bridge private constructor() {
402
402
 
403
403
  /** Wear state from sr_wrst, or from a wear transition on the glasses. */
404
404
  @JvmStatic
405
- fun sendWearState(worn: Boolean) {
405
+ fun sendWearState(worn: Boolean, extras: Map<String, Any> = emptyMap()) {
406
406
  val body = HashMap<String, Any>()
407
407
  body["worn"] = worn
408
+ body.putAll(extras)
408
409
  sendTypedMessage("wear_state", body)
409
410
  }
410
411