@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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayOutputStream
|
|
4
|
+
import java.nio.CharBuffer
|
|
5
|
+
import java.nio.charset.CodingErrorAction
|
|
6
|
+
import java.util.Base64
|
|
7
|
+
import java.util.zip.Deflater
|
|
8
|
+
|
|
9
|
+
/** Pure encoder for the vendor Dynamic Layout V1 contract. No device or Android dependencies. */
|
|
10
|
+
internal object NimoCanvasCodec {
|
|
11
|
+
const val WIDTH = 500
|
|
12
|
+
const val HEIGHT = 220
|
|
13
|
+
const val MAX_STREAM = 12288
|
|
14
|
+
const val APP_ID = 0xFD
|
|
15
|
+
const val LINE_HEIGHT = 20
|
|
16
|
+
const val MAX_IMAGE_BASE64_CHARS = 2_800_000
|
|
17
|
+
|
|
18
|
+
/** Bound and identify host images before handing them to a platform raster decoder. */
|
|
19
|
+
fun imageBytes(input: String): ByteArray {
|
|
20
|
+
require(input.length <= MAX_IMAGE_BASE64_CHARS) { "Encoded canvas image exceeds limit" }
|
|
21
|
+
val mime = when {
|
|
22
|
+
input.startsWith("data:image/png;base64,") -> "png"
|
|
23
|
+
input.startsWith("data:image/bmp;base64,") -> "bmp"
|
|
24
|
+
input.startsWith("data:image/jpeg;base64,") -> "jpeg"
|
|
25
|
+
input.startsWith("data:") -> error("Unsupported canvas image data URI")
|
|
26
|
+
else -> null
|
|
27
|
+
}
|
|
28
|
+
val encoded = if (mime != null) input.substringAfter(',') else input
|
|
29
|
+
require(encoded.length % 4 == 0) { "Canvas image Base64 must be padded" }
|
|
30
|
+
val data = Base64.getDecoder().decode(encoded)
|
|
31
|
+
require(data.size in 24..2_000_000) { "Canvas image file size exceeds limit" }
|
|
32
|
+
val png = data.take(8) == listOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte(), 13.toByte(), 10.toByte(), 26.toByte(), 10.toByte())
|
|
33
|
+
val bmp = data.size >= 54 && data[0] == 0x42.toByte() && data[1] == 0x4D.toByte()
|
|
34
|
+
val jpeg = data.take(3) == listOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte())
|
|
35
|
+
val kind = when { png -> "png"; bmp -> "bmp"; jpeg -> "jpeg"; else -> null }
|
|
36
|
+
require(kind != null && (mime == null || mime == kind)) { "Canvas image must be PNG, BMP or JPEG matching its MIME type" }
|
|
37
|
+
return data
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
data class Object(val type: Int, val payload: ByteArray, val textBytes: Int = 0, val pixels: Int = 0)
|
|
41
|
+
data class Element(val type: String, val x: Int, val y: Int, val width: Int, val height: Int,
|
|
42
|
+
val text: String? = null, val data: String? = null, val border: Int = 0, val radius: Int = 0)
|
|
43
|
+
|
|
44
|
+
/** Pure whole-scene mapping; identity/change annotations intentionally do not enter this API. */
|
|
45
|
+
fun scene(elements: List<Element>, decodeImage: (String, Int, Int) -> ByteArray): ByteArray {
|
|
46
|
+
require(elements.size <= 64)
|
|
47
|
+
var pixels = 0L
|
|
48
|
+
for (element in elements) {
|
|
49
|
+
region(element.x, element.y, element.width, element.height)
|
|
50
|
+
require(element.type in listOf("text", "rect", "image"))
|
|
51
|
+
require(element.border in 0..32 && element.radius in 0..255)
|
|
52
|
+
if (element.type == "image") {
|
|
53
|
+
requireNotNull(element.data)
|
|
54
|
+
pixels += element.width.toLong() * element.height
|
|
55
|
+
require(pixels <= 110000) { "Frame images exceed 110000 pixels before image decoding" }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
val objects = mutableListOf<Object>()
|
|
59
|
+
for (element in elements) {
|
|
60
|
+
with(element) {
|
|
61
|
+
when (type) {
|
|
62
|
+
"text" -> {
|
|
63
|
+
if (border > 0) objects.add(rectangle(x, y, width, height, border, radius))
|
|
64
|
+
objects.addAll(textRows(text ?: "", x, y, width, height))
|
|
65
|
+
}
|
|
66
|
+
"rect" -> objects.add(rectangle(x, y, width, height, maxOf(1, border), radius))
|
|
67
|
+
"image" -> objects.add(bitmap(x, y, width, height, decodeImage(data!!, width, height)))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
require(objects.size <= 64)
|
|
71
|
+
}
|
|
72
|
+
return replace(objects)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fun region(x: Int, y: Int, width: Int, height: Int) {
|
|
76
|
+
require(x in 0 until WIDTH && y in 0 until HEIGHT && width in 1..WIDTH && height in 1..HEIGHT)
|
|
77
|
+
require(x + width <= WIDTH && y + height <= HEIGHT) { "Canvas region exceeds 500x220" }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fun line(x0: Int, y0: Int, x1: Int, y1: Int, stroke: Int = 1, intensity: Int = 255): Object {
|
|
81
|
+
require(x0 in 0 until WIDTH && x1 in 0 until WIDTH && y0 in 0 until HEIGHT && y1 in 0 until HEIGHT)
|
|
82
|
+
require(stroke in 1..32 && intensity in 0..255)
|
|
83
|
+
return Object(0, words(x0, y0, x1, y1) + bytes(stroke, intensity))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun rectangle(x: Int, y: Int, width: Int, height: Int, stroke: Int = 1, radius: Int = 0,
|
|
87
|
+
intensity: Int = 255, filled: Boolean = false): Object {
|
|
88
|
+
region(x, y, width, height)
|
|
89
|
+
require(stroke in 0..32 && radius in 0..255 && intensity in 0..255)
|
|
90
|
+
return Object(1, words(x, y, width, height) + bytes(stroke, radius, intensity, if (filled) 1 else 0))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fun circle(x: Int, y: Int, radius: Int, stroke: Int = 1, intensity: Int = 255, filled: Boolean = false): Object {
|
|
94
|
+
require(radius in 1..110 && x - radius >= 0 && y - radius >= 0 && x + radius < WIDTH && y + radius < HEIGHT)
|
|
95
|
+
require(stroke in 0..32 && intensity in 0..255)
|
|
96
|
+
return Object(2, words(x, y, radius) + bytes(stroke, intensity, if (filled) 1 else 0))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fun label(text: String, x: Int, y: Int, width: Int, height: Int, font: Int = 0,
|
|
100
|
+
language: Int = 0, intensity: Int = 255): Object {
|
|
101
|
+
region(x, y, width, height)
|
|
102
|
+
require(font in 0..3 && language in 0..20 && intensity in 0..255)
|
|
103
|
+
require(text.length <= 2048 && '\u0000' !in text)
|
|
104
|
+
val encoder = Charsets.UTF_8.newEncoder().onMalformedInput(CodingErrorAction.REPORT)
|
|
105
|
+
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
|
106
|
+
val encoded = encoder.encode(CharBuffer.wrap(text))
|
|
107
|
+
val utf8 = ByteArray(encoded.remaining()).also { encoded.get(it) }
|
|
108
|
+
require(utf8.size in 1..2048) { "Label exceeds 2048 UTF-8 bytes" }
|
|
109
|
+
return Object(3, words(x, y, width, height, language) + bytes(font, intensity) + words(utf8.size) + utf8,
|
|
110
|
+
textBytes = utf8.size)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Host-prewrapped rows retain their y positions, including empty rows. */
|
|
114
|
+
fun textRows(text: String, x: Int, y: Int, width: Int, height: Int): List<Object> {
|
|
115
|
+
region(x, y, width, height)
|
|
116
|
+
require(text.length <= 8192 && '\u0000' !in text)
|
|
117
|
+
if (!text.contains('\n')) return if (text.isEmpty()) emptyList()
|
|
118
|
+
else listOf(label(text, x, y, width, minOf(LINE_HEIGHT, height)))
|
|
119
|
+
val rows = text.split('\n')
|
|
120
|
+
require(rows.size <= 64)
|
|
121
|
+
return rows.mapIndexedNotNull { index, row ->
|
|
122
|
+
val offset = index * LINE_HEIGHT
|
|
123
|
+
if (offset >= height || row.isEmpty()) null
|
|
124
|
+
else label(row, x, y + offset, width, minOf(LINE_HEIGHT, height - offset))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 00=white, 11=black. Padding is black and packing continues across row boundaries. */
|
|
129
|
+
fun pack2(gray: ByteArray): ByteArray {
|
|
130
|
+
require(gray.size in 1..110000)
|
|
131
|
+
val out = ByteArray((gray.size + 3) / 4) { 0xFF.toByte() }
|
|
132
|
+
for (i in gray.indices) {
|
|
133
|
+
val shift = 6 - 2 * (i % 4)
|
|
134
|
+
val code = ((255 - (gray[i].toInt() and 255) + 42) / 85).coerceIn(0, 3)
|
|
135
|
+
out[i / 4] = ((out[i / 4].toInt() and (3 shl shift).inv()) or (code shl shift)).toByte()
|
|
136
|
+
}
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fun rle(data: ByteArray): ByteArray {
|
|
141
|
+
require(data.size <= 110000)
|
|
142
|
+
val out = ByteArrayOutputStream()
|
|
143
|
+
var i = 0
|
|
144
|
+
fun run(start: Int): Int {
|
|
145
|
+
var n = 1
|
|
146
|
+
while (start + n < data.size && n < 127 && data[start + n] == data[start]) n++
|
|
147
|
+
return n
|
|
148
|
+
}
|
|
149
|
+
while (i < data.size) {
|
|
150
|
+
val count = run(i)
|
|
151
|
+
if (count >= 3) {
|
|
152
|
+
out.write(count); out.write(data[i].toInt() and 255); i += count
|
|
153
|
+
} else {
|
|
154
|
+
val start = i
|
|
155
|
+
i += count
|
|
156
|
+
while (i < data.size && i - start < 127) {
|
|
157
|
+
val next = run(i)
|
|
158
|
+
if (next >= 3) break
|
|
159
|
+
i += minOf(next, 127 - (i - start))
|
|
160
|
+
}
|
|
161
|
+
out.write(0x80 or (i - start)); out.write(data, start, i - start)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out.toByteArray()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fun bitmap(x: Int, y: Int, width: Int, height: Int, gray: ByteArray): Object {
|
|
168
|
+
region(x, y, width, height)
|
|
169
|
+
require(gray.size == width * height)
|
|
170
|
+
val packed = pack2(gray)
|
|
171
|
+
val rle = rle(packed)
|
|
172
|
+
val zlib = zlib(packed)
|
|
173
|
+
val compression = if (rle.size <= zlib.size) 3 else 7
|
|
174
|
+
val body = if (compression == 3) rle else zlib
|
|
175
|
+
val packet = bytes(0) + words(width, height) + bytes(2, compression) + dword(packed.size) + dword(body.size) + body
|
|
176
|
+
require(packet.size <= 65535)
|
|
177
|
+
return Object(4, words(x, y, width, height, 0, 1, packet.size) + packet, pixels = gray.size)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fun replace(objects: List<Object>): ByteArray {
|
|
181
|
+
require(objects.size <= 64) { "More than 64 canvas objects" }
|
|
182
|
+
require(objects.sumOf { it.textBytes.toLong() } <= 8192) { "Frame text exceeds 8192 bytes" }
|
|
183
|
+
require(objects.sumOf { it.pixels.toLong() } <= 110000) { "Frame images exceed 110000 pixels" }
|
|
184
|
+
val size = 3L + objects.sumOf { 3L + it.payload.size }
|
|
185
|
+
require(size <= MAX_STREAM - 8) { "Canvas frame exceeds Companion reassembly budget" }
|
|
186
|
+
val out = ByteArrayOutputStream(size.toInt())
|
|
187
|
+
out.write(words(objects.size)); out.write(1)
|
|
188
|
+
objects.forEach { require(it.type in 0..4); out.write(it.type); out.write(words(it.payload.size)); out.write(it.payload) }
|
|
189
|
+
return out.toByteArray()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** [content] is a V1 frame for Update, absent for Launch and Exit. */
|
|
193
|
+
fun frames(key: Int, content: ByteArray = ByteArray(0), writeCapacity: Int): List<ByteArray> {
|
|
194
|
+
require(writeCapacity in 20..512)
|
|
195
|
+
val payload = when (key) {
|
|
196
|
+
1 -> bytes(APP_ID, 0)
|
|
197
|
+
3 -> bytes(APP_ID)
|
|
198
|
+
4 -> bytes(APP_ID, 0, 0, 0) + content
|
|
199
|
+
else -> error("Unsupported canvas command")
|
|
200
|
+
}
|
|
201
|
+
val stream = bytes(7, key) + words(payload.size) + payload
|
|
202
|
+
require(stream.size <= MAX_STREAM)
|
|
203
|
+
val chunkSize = writeCapacity - 8
|
|
204
|
+
val chunks = (stream.size + chunkSize - 1) / chunkSize
|
|
205
|
+
return (0 until chunks).map { index ->
|
|
206
|
+
val data = stream.copyOfRange(index * chunkSize, minOf(stream.size, (index + 1) * chunkSize))
|
|
207
|
+
bytes(0xBF, 0) + words(data.size, crc16(data), if (index == chunks - 1) 0 else index + 1) + data
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Canvas ACKs are unfragmented and use the normal payload length INCLUDING status. */
|
|
212
|
+
fun response(packet: ByteArray): Pair<Int, ByteArray>? {
|
|
213
|
+
if (packet.size < 13 || packet[0].toInt() and 255 != 0xBF || packet[1].toInt() != 0) return null
|
|
214
|
+
fun word(i: Int) = (packet[i].toInt() and 255) or ((packet[i + 1].toInt() and 255) shl 8)
|
|
215
|
+
if (word(2) != packet.size - 8 || word(6) != 0 || packet[8].toInt() != 7) return null
|
|
216
|
+
if (word(4) != crc16(packet.copyOfRange(8, packet.size)) || word(10) != packet.size - 12) return null
|
|
217
|
+
val key = packet[9].toInt() and 255
|
|
218
|
+
if (key !in listOf(1, 3, 4)) return null
|
|
219
|
+
return key to packet.copyOfRange(12, packet.size)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
fun crc16(data: ByteArray): Int {
|
|
223
|
+
var crc = 0xFFFF
|
|
224
|
+
for (byte in data) {
|
|
225
|
+
crc = crc xor ((byte.toInt() and 255) shl 8)
|
|
226
|
+
repeat(8) { crc = (if (crc and 0x8000 != 0) (crc shl 1) xor 0x1021 else crc shl 1) and 0xFFFF }
|
|
227
|
+
}
|
|
228
|
+
return crc
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private fun zlib(data: ByteArray): ByteArray {
|
|
232
|
+
val deflater = Deflater(6)
|
|
233
|
+
return try {
|
|
234
|
+
deflater.setInput(data); deflater.finish()
|
|
235
|
+
val out = ByteArrayOutputStream(); val chunk = ByteArray(4096)
|
|
236
|
+
while (!deflater.finished()) { val n = deflater.deflate(chunk); check(n > 0); out.write(chunk, 0, n) }
|
|
237
|
+
out.toByteArray()
|
|
238
|
+
} finally { deflater.end() }
|
|
239
|
+
}
|
|
240
|
+
private fun bytes(vararg values: Int) = values.map { it.toByte() }.toByteArray()
|
|
241
|
+
private fun words(vararg values: Int): ByteArray = values.flatMap {
|
|
242
|
+
require(it in 0..65535); listOf(it.toByte(), (it shr 8).toByte())
|
|
243
|
+
}.toByteArray()
|
|
244
|
+
private fun dword(value: Int) = bytes(value, value shr 8, value shr 16, value shr 24)
|
|
245
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
/** A cancelable clock boundary; production uses the main Handler. */
|
|
4
|
+
internal fun interface NimoScheduler {
|
|
5
|
+
fun post(delayMs: Long, task: () -> Unit): () -> Unit
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The single queue for ALL NIMO characteristic and notification-descriptor writes. */
|
|
9
|
+
internal class NimoGattQueue<C : Any>(
|
|
10
|
+
private val scheduler: NimoScheduler,
|
|
11
|
+
private val write: (C, ByteArray) -> Boolean,
|
|
12
|
+
private val onFailure: (String) -> Unit,
|
|
13
|
+
private val pacingMs: Long = 5,
|
|
14
|
+
) {
|
|
15
|
+
private class Write<C>(val characteristic: C, val bytes: ByteArray,
|
|
16
|
+
val started: () -> Unit, val completed: () -> Unit) {
|
|
17
|
+
var attempts = 0
|
|
18
|
+
var callbackReceived = false
|
|
19
|
+
}
|
|
20
|
+
private val queue = ArrayDeque<Write<C>>()
|
|
21
|
+
private var connection: Any? = null
|
|
22
|
+
private var generation = 0L
|
|
23
|
+
private var current: Write<C>? = null
|
|
24
|
+
private var descriptor: Any? = null
|
|
25
|
+
private var descriptorCompletion: (() -> Unit)? = null
|
|
26
|
+
private var cancelDeadline: (() -> Unit)? = null
|
|
27
|
+
|
|
28
|
+
fun connected(connection: Any) { reset(); this.connection = connection }
|
|
29
|
+
|
|
30
|
+
fun reset() {
|
|
31
|
+
generation++
|
|
32
|
+
connection = null
|
|
33
|
+
current = null
|
|
34
|
+
descriptor = null
|
|
35
|
+
descriptorCompletion = null
|
|
36
|
+
queue.clear()
|
|
37
|
+
cancelDeadline?.invoke()
|
|
38
|
+
cancelDeadline = null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fun subscribe(connection: Any, descriptor: Any, start: () -> Boolean, completed: () -> Unit) {
|
|
42
|
+
if (connection !== this.connection) return
|
|
43
|
+
if (current != null || this.descriptor != null) { fail("Overlapping notification subscription"); return }
|
|
44
|
+
this.descriptor = descriptor
|
|
45
|
+
descriptorCompletion = completed
|
|
46
|
+
if (!start()) { fail("Could not write notification descriptor"); return }
|
|
47
|
+
val expected = generation
|
|
48
|
+
cancelDeadline = scheduler.post(5_000) {
|
|
49
|
+
if (generation == expected && this.descriptor === descriptor) fail("Notification subscription timed out")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fun descriptorWritten(connection: Any, descriptor: Any, success: Boolean) {
|
|
54
|
+
if (connection !== this.connection || descriptor !== this.descriptor) return
|
|
55
|
+
cancelDeadline?.invoke(); cancelDeadline = null
|
|
56
|
+
this.descriptor = null
|
|
57
|
+
val completed = descriptorCompletion
|
|
58
|
+
descriptorCompletion = null
|
|
59
|
+
if (!success) { fail("Notification subscription failed"); return }
|
|
60
|
+
completed?.invoke() // May install the next subscription before writes are released.
|
|
61
|
+
drain()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fun enqueue(connection: Any, characteristic: C, frames: List<ByteArray>,
|
|
65
|
+
finalStarted: () -> Unit = {}, completed: () -> Unit = {}): Boolean {
|
|
66
|
+
if (connection !== this.connection) return false
|
|
67
|
+
if (frames.isEmpty()) return false
|
|
68
|
+
if (queue.size + frames.size > 1100) { fail("Fragment chain exceeds write queue budget"); return false }
|
|
69
|
+
frames.forEachIndexed { index, bytes ->
|
|
70
|
+
val last = index == frames.lastIndex
|
|
71
|
+
queue.addLast(Write(characteristic, bytes.copyOf(), if (last) finalStarted else ({}),
|
|
72
|
+
if (last) completed else ({})))
|
|
73
|
+
}
|
|
74
|
+
drain()
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fun written(connection: Any, characteristic: C, success: Boolean) {
|
|
79
|
+
val item = current ?: return
|
|
80
|
+
if (connection !== this.connection || characteristic !== item.characteristic || item.callbackReceived) return
|
|
81
|
+
item.callbackReceived = true
|
|
82
|
+
cancelDeadline?.invoke(); cancelDeadline = null
|
|
83
|
+
if (!success) { fail("GATT write failed"); return }
|
|
84
|
+
val expected = generation
|
|
85
|
+
scheduler.post(pacingMs) {
|
|
86
|
+
if (generation != expected || current !== item) return@post
|
|
87
|
+
current = null
|
|
88
|
+
item.completed()
|
|
89
|
+
drain()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private fun drain() {
|
|
94
|
+
if (connection == null || current != null || descriptor != null) return
|
|
95
|
+
val item = queue.removeFirstOrNull() ?: return
|
|
96
|
+
current = item
|
|
97
|
+
val expected = generation
|
|
98
|
+
val accepted = try { write(item.characteristic, item.bytes) } catch (_: Exception) { false }
|
|
99
|
+
if (!accepted) {
|
|
100
|
+
current = null
|
|
101
|
+
if (++item.attempts >= 3) { fail("GATT rejected write after three attempts"); return }
|
|
102
|
+
queue.addFirst(item)
|
|
103
|
+
scheduler.post(100) { if (expected == generation) drain() }
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
item.started()
|
|
107
|
+
cancelDeadline = scheduler.post(3_000) {
|
|
108
|
+
if (expected == generation && current === item && !item.callbackReceived) fail("GATT write timed out")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private fun fail(reason: String) { reset(); onFailure(reason) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Business ACKs cannot advance the session until their complete fragment chain was written. */
|
|
116
|
+
internal class NimoCanvasCoordinator(
|
|
117
|
+
private val scheduler: NimoScheduler,
|
|
118
|
+
private val writeCapacity: () -> Int,
|
|
119
|
+
private val enqueue: (List<ByteArray>, () -> Unit, () -> Unit) -> Boolean,
|
|
120
|
+
private val reconnect: (String) -> Unit,
|
|
121
|
+
private val rejected: (Int) -> Unit = {},
|
|
122
|
+
private val holdPreempted: () -> Unit = {},
|
|
123
|
+
) {
|
|
124
|
+
private class Flight(val action: NimoCanvasSession.Action.Send) {
|
|
125
|
+
var finalStarted = false
|
|
126
|
+
var complete = false
|
|
127
|
+
var earlyAck: ByteArray? = null
|
|
128
|
+
}
|
|
129
|
+
private val session = NimoCanvasSession()
|
|
130
|
+
private var flight: Flight? = null
|
|
131
|
+
private var holdReady: (() -> Unit)? = null
|
|
132
|
+
private var holding = false
|
|
133
|
+
private var heldScope: String? = null
|
|
134
|
+
private var cancelDeadline: (() -> Unit)? = null
|
|
135
|
+
|
|
136
|
+
fun offer(bytes: ByteArray, scope: String, force: Boolean = false) {
|
|
137
|
+
if (holding && scope != heldScope) {
|
|
138
|
+
releaseHold(resume = false)
|
|
139
|
+
holdPreempted()
|
|
140
|
+
}
|
|
141
|
+
run(session.offer(bytes, scope, force))
|
|
142
|
+
}
|
|
143
|
+
fun readiness(ready: Boolean) = run(session.readiness(ready))
|
|
144
|
+
fun confirmedReadiness(ready: Boolean) = run(session.confirmedReadiness(ready))
|
|
145
|
+
fun hold(onReady: () -> Unit) {
|
|
146
|
+
session.hold(true)
|
|
147
|
+
holding = true
|
|
148
|
+
heldScope = session.currentScope()
|
|
149
|
+
holdReady = onReady
|
|
150
|
+
notifyHoldReadyIfIdle()
|
|
151
|
+
}
|
|
152
|
+
fun releaseHold(resume: Boolean = true) {
|
|
153
|
+
holdReady = null
|
|
154
|
+
holding = false
|
|
155
|
+
heldScope = null
|
|
156
|
+
run(session.hold(false, resume))
|
|
157
|
+
}
|
|
158
|
+
fun exit() = run(session.exit())
|
|
159
|
+
/** Whether this report also invalidates an unfinished host encode. Exit's report does not
|
|
160
|
+
* invalidate a NEW scene submitted while the explicit Exit waits for its business ACK. */
|
|
161
|
+
fun nativeApp(appId: Int, entered: Boolean): Boolean {
|
|
162
|
+
val takeover = (appId == NimoCanvasCodec.APP_ID && !entered) ||
|
|
163
|
+
(appId != NimoCanvasCodec.APP_ID && entered)
|
|
164
|
+
val expectedExitReport = appId == NimoCanvasCodec.APP_ID && !entered && flight?.action?.key == 3
|
|
165
|
+
val invalidateEncoding = takeover && !expectedExitReport
|
|
166
|
+
run(session.nativeApp(appId, entered))
|
|
167
|
+
return invalidateEncoding
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fun disconnected() {
|
|
171
|
+
cancelDeadline?.invoke(); cancelDeadline = null
|
|
172
|
+
flight = null
|
|
173
|
+
holdReady = null
|
|
174
|
+
holding = false
|
|
175
|
+
heldScope = null
|
|
176
|
+
session.disconnected()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fun response(key: Int, payload: ByteArray) {
|
|
180
|
+
val current = flight ?: return
|
|
181
|
+
if (!session.acceptsResponse(key, payload)) return
|
|
182
|
+
if (!current.complete) {
|
|
183
|
+
if (current.finalStarted && current.earlyAck == null) current.earlyAck = payload.copyOf()
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
cancelDeadline?.invoke(); cancelDeadline = null
|
|
187
|
+
flight = null
|
|
188
|
+
run(session.response(key, payload))
|
|
189
|
+
notifyHoldReadyIfIdle()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private fun notifyHoldReadyIfIdle() {
|
|
193
|
+
if (flight != null) return
|
|
194
|
+
val ready = holdReady ?: return
|
|
195
|
+
holdReady = null
|
|
196
|
+
ready()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun run(actions: List<NimoCanvasSession.Action>) {
|
|
200
|
+
for (action in actions) when (action) {
|
|
201
|
+
is NimoCanvasSession.Action.Send -> {
|
|
202
|
+
cancelDeadline?.invoke()
|
|
203
|
+
val current = Flight(action)
|
|
204
|
+
flight = current
|
|
205
|
+
cancelDeadline = scheduler.post(45_000) {
|
|
206
|
+
if (flight === current) run(session.timeout(action.ticket))
|
|
207
|
+
}
|
|
208
|
+
val queued = enqueue(NimoCanvasCodec.frames(action.key, action.frame, writeCapacity()),
|
|
209
|
+
{ if (flight === current) current.finalStarted = true },
|
|
210
|
+
{
|
|
211
|
+
if (flight === current) {
|
|
212
|
+
current.complete = true
|
|
213
|
+
current.earlyAck?.let { response(action.key, it) }
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
if (!queued && flight === current) {
|
|
217
|
+
disconnected()
|
|
218
|
+
reconnect("Canvas transport unavailable")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
is NimoCanvasSession.Action.Reconnect -> { disconnected(); reconnect(action.reason) }
|
|
222
|
+
is NimoCanvasSession.Action.Rejected -> rejected(action.status)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
/** Owns latest-only background work, including resources belonging to canceled bitmap requests. */
|
|
4
|
+
internal class NimoCanvasEncoder(
|
|
5
|
+
private val main: NimoScheduler,
|
|
6
|
+
private val worker: NimoScheduler,
|
|
7
|
+
private val stopWorker: () -> Unit,
|
|
8
|
+
private val failed: (Exception) -> Unit,
|
|
9
|
+
) {
|
|
10
|
+
private class Request(val generation: Long, val encode: () -> ByteArray,
|
|
11
|
+
val release: () -> Unit, val deliver: (ByteArray) -> Unit) {
|
|
12
|
+
var cancel: (() -> Unit)? = null
|
|
13
|
+
}
|
|
14
|
+
private var generation = 0L
|
|
15
|
+
private var pending: Request? = null
|
|
16
|
+
private var closed = false
|
|
17
|
+
|
|
18
|
+
@Synchronized
|
|
19
|
+
fun submit(encode: () -> ByteArray, release: () -> Unit = {}, deliver: (ByteArray) -> Unit): Boolean {
|
|
20
|
+
if (closed) { release(); return false }
|
|
21
|
+
invalidate()
|
|
22
|
+
val request = Request(generation, encode, release, deliver)
|
|
23
|
+
pending = request
|
|
24
|
+
request.cancel = worker.post(0) { encode(request) }
|
|
25
|
+
return true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Synchronized
|
|
29
|
+
fun invalidate() {
|
|
30
|
+
generation++
|
|
31
|
+
pending?.let { it.cancel?.invoke(); it.release() }
|
|
32
|
+
pending = null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Synchronized
|
|
36
|
+
fun close() {
|
|
37
|
+
if (closed) return
|
|
38
|
+
closed = true
|
|
39
|
+
invalidate()
|
|
40
|
+
stopWorker()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private fun encode(request: Request) {
|
|
44
|
+
synchronized(this) {
|
|
45
|
+
if (pending !== request) return
|
|
46
|
+
pending = null // Running work releases its own source, never the invalidator.
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
val bytes = request.encode()
|
|
50
|
+
main.post(0) {
|
|
51
|
+
synchronized(this) {
|
|
52
|
+
if (!closed && generation == request.generation) request.deliver(bytes)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (error: Exception) { failed(error) }
|
|
56
|
+
finally { request.release() }
|
|
57
|
+
}
|
|
58
|
+
}
|