@mentra/bluetooth-sdk 3.2.1-beta.319 → 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.
- package/README.md +1 -1
- 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/BluetoothSdkDefaults.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +67 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
- 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 +11 -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/VersionInfoResponseAccumulatorTest.kt +71 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -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/BluetoothSdk.types.d.ts +11 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +3 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +5 -0
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothSdkDefaults.swift +6 -1
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +23 -10
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +4 -0
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
- package/ios/Source/sgcs/G2.swift +2 -2
- package/ios/Source/sgcs/MentraLive.swift +66 -3
- 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 +24 -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/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +11 -0
- package/src/_private/BluetoothSdkModule.ts +6 -0
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- 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
|
|