@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.
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
@@ -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
+ }