@mentra/bluetooth-sdk 0.1.18 → 0.1.19
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/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
- package/build/BluetoothSdk.types.d.ts +12 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/index.js +2 -0
- package/build/types/index.js.map +1 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +134 -29
- package/ios/Source/DeviceStore.swift +0 -5
- package/ios/Source/MentraBluetoothSDK.swift +26 -23
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +308 -30
- package/ios/Source/sgcs/MentraNex.swift +416 -32
- package/ios/Source/sgcs/SGCManager.swift +155 -0
- package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/utils/BleJsonCompact.swift +395 -0
- package/ios/Source/utils/BleWireProtocol.swift +92 -0
- package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
- package/ios/Source/utils/MessageChunker.swift +73 -0
- package/package.json +14 -7
- package/src/BluetoothSdk.types.ts +13 -1
- package/src/types/index.ts +7 -0
|
@@ -5,7 +5,12 @@ import com.mentra.bluetoothsdk.DeviceManager
|
|
|
5
5
|
import com.mentra.bluetoothsdk.PhotoRequest
|
|
6
6
|
import com.mentra.bluetoothsdk.DeviceStore
|
|
7
7
|
|
|
8
|
+
import android.graphics.Bitmap
|
|
8
9
|
import android.graphics.BitmapFactory
|
|
10
|
+
import android.graphics.Canvas
|
|
11
|
+
import android.graphics.ColorMatrix
|
|
12
|
+
import android.graphics.ColorMatrixColorFilter
|
|
13
|
+
import android.graphics.Paint
|
|
9
14
|
import android.bluetooth.BluetoothAdapter
|
|
10
15
|
import android.bluetooth.BluetoothDevice
|
|
11
16
|
import android.bluetooth.BluetoothGatt
|
|
@@ -75,6 +80,13 @@ class MentraNex : SGCManager() {
|
|
|
75
80
|
// callback is normally near-instant; this only guards against a callback that
|
|
76
81
|
// never arrives, so a dropped write can't wedge the send queue forever.
|
|
77
82
|
private const val WRITE_CALLBACK_TIMEOUT_MS: Long = 10
|
|
83
|
+
// Upper bound for an acked (write-with-response) fragment. The callback only
|
|
84
|
+
// fires after the firmware's ATT Write Response — at least one connection
|
|
85
|
+
// interval — so 10ms is far too short here: timing out early would let the
|
|
86
|
+
// worker issue the next write while this one is still outstanding, which the
|
|
87
|
+
// stack rejects (one GATT op at a time) and the fragment is dropped. This is a
|
|
88
|
+
// safety bound only; the ack normally arrives within an interval or two.
|
|
89
|
+
private const val WRITE_ACK_TIMEOUT_MS: Long = 400
|
|
78
90
|
|
|
79
91
|
private const val INITIAL_CONNECTION_DELAY_MS = 350L // Adjust this value as needed
|
|
80
92
|
// Window to let a queued DisconnectRequest flush to the glasses before we close GATT.
|
|
@@ -97,6 +109,14 @@ class MentraNex : SGCManager() {
|
|
|
97
109
|
// actions of NEX Glasses
|
|
98
110
|
private const val MAIN_TASK_HANDLER_CODE_BATTERY_QUERY: Int = 620
|
|
99
111
|
private const val MAIN_TASK_HANDLER_CODE_HEART_BEAT: Int = 630
|
|
112
|
+
|
|
113
|
+
// Fixed rect for the live-captions box (G2-style): a single full-canvas text container
|
|
114
|
+
// reused across caption updates, so each caption is an in-place text change that coexists
|
|
115
|
+
// with any bitmap/other components. Matches the 500x220 Mentra display canvas.
|
|
116
|
+
private const val CAPTION_BOX_X: Int = 0
|
|
117
|
+
private const val CAPTION_BOX_Y: Int = 0
|
|
118
|
+
private const val CAPTION_BOX_W: Int = 500
|
|
119
|
+
private const val CAPTION_BOX_H: Int = 220
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
private var heartbeatCount: Int = 0;
|
|
@@ -252,7 +272,12 @@ class MentraNex : SGCManager() {
|
|
|
252
272
|
private var currentImageChunks: MutableList<ByteArray> = mutableListOf()
|
|
253
273
|
|
|
254
274
|
// Data class to represent a send request
|
|
255
|
-
|
|
275
|
+
// ack=true issues the fragment as a write-WITH-response (WRITE_TYPE_DEFAULT): the
|
|
276
|
+
// firmware ATT-acks each write, so the worker knows it landed. ack=false is a
|
|
277
|
+
// no-response write (WRITE_TYPE_NO_RESPONSE) — fire-and-forget, used for captions
|
|
278
|
+
// where the newest frame supersedes the last and throughput matters more than
|
|
279
|
+
// per-packet delivery. See processQueue() for how the write type is applied.
|
|
280
|
+
private data class SendRequest( val data: ByteArray, var waitTime: Int = -1, val ack: Boolean = true) {
|
|
256
281
|
override fun equals(other: Any?): Boolean {
|
|
257
282
|
if (this === other) return true
|
|
258
283
|
if (javaClass != other?.javaClass) return false
|
|
@@ -261,6 +286,7 @@ class MentraNex : SGCManager() {
|
|
|
261
286
|
|
|
262
287
|
if (!data.contentEquals(other.data)) return false
|
|
263
288
|
if (waitTime != other.waitTime) return false
|
|
289
|
+
if (ack != other.ack) return false
|
|
264
290
|
|
|
265
291
|
return true
|
|
266
292
|
}
|
|
@@ -268,6 +294,7 @@ class MentraNex : SGCManager() {
|
|
|
268
294
|
override fun hashCode(): Int {
|
|
269
295
|
var result = data.contentHashCode()
|
|
270
296
|
result = 31 * result + waitTime
|
|
297
|
+
result = 31 * result + ack.hashCode()
|
|
271
298
|
return result
|
|
272
299
|
}
|
|
273
300
|
}
|
|
@@ -496,61 +523,236 @@ class MentraNex : SGCManager() {
|
|
|
496
523
|
sendProtobuf(autoBrightnessCmdBytes, 10)
|
|
497
524
|
}
|
|
498
525
|
|
|
499
|
-
override fun clearDisplay() {
|
|
500
|
-
Bridge.log("Nex: clearDisplay() -
|
|
526
|
+
override fun clearDisplay() {
|
|
527
|
+
Bridge.log("Nex: clearDisplay() - clearing canvas + caption");
|
|
528
|
+
// Tear down any canvas components (bitmap + positioned text) and forget the element mapping
|
|
529
|
+
// + active layout, then clear the caption text path too so either kind of screen is wiped.
|
|
530
|
+
canvasElements.clear()
|
|
531
|
+
currentLayoutId = null
|
|
532
|
+
sendProtobuf(NexProtobufUtils.generateCanvasClearCommandBytes(), 10)
|
|
501
533
|
val clearDisplayPackets = NexProtobufUtils.generateClearDisplayRequestCommandBytes()
|
|
502
534
|
sendProtobuf(clearDisplayPackets, 10)
|
|
503
|
-
|
|
504
|
-
Bridge.log("Nex: clearDisplay() - sent clear display request command bytes");
|
|
535
|
+
Bridge.log("Nex: clearDisplay() - sent");
|
|
505
536
|
}
|
|
506
537
|
|
|
507
538
|
|
|
508
539
|
override fun sendText(text: String) {
|
|
509
540
|
Bridge.log("Nex: sendText() - text: " + text);
|
|
510
|
-
|
|
511
|
-
sendProtobuf(textChunks)
|
|
541
|
+
showCaptionText(text)
|
|
512
542
|
}
|
|
513
543
|
|
|
514
544
|
override fun sendTextWall(text: String) {
|
|
515
545
|
Bridge.log("Nex: sendTextWall() - text: " + text);
|
|
516
|
-
|
|
517
|
-
sendProtobuf(textChunks)
|
|
546
|
+
showCaptionText(text)
|
|
518
547
|
}
|
|
519
548
|
|
|
520
549
|
override fun sendDoubleTextWall(top: String, bottom: String) {
|
|
521
550
|
Bridge.log("Nex: sendDoubleTextWall() - top: " + top + ", bottom: " + bottom);
|
|
522
|
-
val finalText: String =
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
551
|
+
val finalText: String = listOf(top, bottom).filter { it.isNotEmpty() }.joinToString("\n")
|
|
552
|
+
showCaptionText(finalText)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Live captions / text walls render into a single reused canvas text container (the caption
|
|
556
|
+
// box), like Even G2: the first call creates it, each subsequent call is an in-place text
|
|
557
|
+
// update on the same component (reused by rect via sendPositionedText), so captions can sit
|
|
558
|
+
// alongside a navigation minimap or other canvas components instead of owning the screen.
|
|
559
|
+
private fun showCaptionText(text: String) {
|
|
560
|
+
// Captions are high-frequency, replace-in-place updates where the newest frame
|
|
561
|
+
// supersedes the last, so they go out WITHOUT ATT acks (write-no-response) for
|
|
562
|
+
// throughput — every other command uses acked writes. Route through upsertText
|
|
563
|
+
// directly (rather than the public sendPositionedText override, which can't take
|
|
564
|
+
// an ack flag) using the same rect-synthesized element id so reuse still works.
|
|
565
|
+
val elementId = "text@$CAPTION_BOX_X,$CAPTION_BOX_Y,${CAPTION_BOX_W}x$CAPTION_BOX_H,0,0"
|
|
566
|
+
upsertText(elementId, null, text, CAPTION_BOX_X, CAPTION_BOX_Y, CAPTION_BOX_W, CAPTION_BOX_H, 0, 0, ack = false)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---- Canvas text containers (G2-style) -------------------------------------------------
|
|
570
|
+
// Positioned text is drawn as canvas TEXTBOX components so it coexists with the bitmap
|
|
571
|
+
// component (a navigation screen = minimap + several text boxes shown together). Components
|
|
572
|
+
// are keyed by their rect+border and reused so repeated updates don't recreate the LVGL
|
|
573
|
+
// object; ids come from the firmware's text pool (1..6) with oldest-out eviction.
|
|
574
|
+
// Retained-mode canvas registry: elementId -> firmware component. The layout path (nav HUD)
|
|
575
|
+
// passes explicit element ids; legacy callers (captions, one-off bitmaps) synthesize an id from
|
|
576
|
+
// their rect, so reuse/update works for them through the same path. Text ids come from the
|
|
577
|
+
// firmware pool 1..6, bitmaps from 10..13.
|
|
578
|
+
private data class CanvasElement(val firmwareId: Int, val isBitmap: Boolean, val rect: String)
|
|
579
|
+
private val canvasElements = LinkedHashMap<String, CanvasElement>()
|
|
580
|
+
private val canvasTextIdPool: List<Int> = listOf(1, 2, 3, 4, 5, 6)
|
|
581
|
+
private val canvasBitmapIdPool: List<Int> = listOf(10, 11, 12, 13)
|
|
582
|
+
// Active layout/scene id. A display call carrying a DIFFERENT layoutId clears the previous scene
|
|
583
|
+
// first (retained-mode contract); legacy calls (null layoutId) leave it untouched.
|
|
584
|
+
private var currentLayoutId: String? = null
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Handle a scene/layout change. DeviceManager sweeps the previous app's
|
|
588
|
+
* elements before a cross-app frame arrives, so the registry is usually
|
|
589
|
+
* empty here and switching costs nothing. Only when stale components remain
|
|
590
|
+
* (e.g. legacy callers with no sweep) do we delete them — individually, NOT
|
|
591
|
+
* via CanvasClear: clear also exits the canvas VIEW (the first create
|
|
592
|
+
* re-activates it), which reads as a full-screen flash.
|
|
593
|
+
*/
|
|
594
|
+
private fun ensureLayout(layoutId: String?) {
|
|
595
|
+
if (layoutId != null && layoutId != currentLayoutId) {
|
|
596
|
+
if (canvasElements.isNotEmpty()) {
|
|
597
|
+
for (el in canvasElements.values) {
|
|
598
|
+
sendProtobuf(NexProtobufUtils.generateCanvasDeleteComponentCommandBytes(el.firmwareId))
|
|
599
|
+
}
|
|
600
|
+
canvasElements.clear()
|
|
601
|
+
}
|
|
602
|
+
currentLayoutId = layoutId
|
|
526
603
|
}
|
|
527
|
-
val textChunks = createTextWallChunksForNex(finalText)
|
|
528
|
-
sendProtobuf(textChunks)
|
|
529
604
|
}
|
|
530
605
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
606
|
+
/**
|
|
607
|
+
* A replay frame (reconnect / firmware recovery / re-dispatch) repaints from
|
|
608
|
+
* scratch: forget the registry so every element takes the CREATE path.
|
|
609
|
+
* Create-on-existing-id is replace in firmware, so repainting over a live
|
|
610
|
+
* canvas is safe, and updates-to-dead-ids (which firmware drops silently)
|
|
611
|
+
* can never happen.
|
|
612
|
+
*/
|
|
613
|
+
override fun onSceneReplay(appId: String) {
|
|
614
|
+
canvasElements.clear()
|
|
615
|
+
currentLayoutId = appId
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Free firmware id from the type's pool. When the pool is exhausted the
|
|
620
|
+
* OLDEST registered element of that type is evicted for real: its firmware
|
|
621
|
+
* component is deleted and its id reused (LinkedHashMap preserves insertion
|
|
622
|
+
* order).
|
|
623
|
+
*/
|
|
624
|
+
private fun allocFirmwareId(isBitmap: Boolean): Int? {
|
|
625
|
+
val pool = if (isBitmap) canvasBitmapIdPool else canvasTextIdPool
|
|
626
|
+
val used = canvasElements.values.filter { it.isBitmap == isBitmap }.map { it.firmwareId }.toSet()
|
|
627
|
+
pool.firstOrNull { it !in used }?.let {
|
|
628
|
+
return it
|
|
629
|
+
}
|
|
630
|
+
// Pool full — evict the oldest same-type element.
|
|
631
|
+
val oldest = canvasElements.entries.firstOrNull { it.value.isBitmap == isBitmap } ?: return null
|
|
632
|
+
Bridge.log("Nex: pool full — evicting oldest ${if (isBitmap) "bitmap" else "text"} element '${oldest.key}' (fw id ${oldest.value.firmwareId})")
|
|
633
|
+
sendProtobuf(NexProtobufUtils.generateCanvasDeleteComponentCommandBytes(oldest.value.firmwareId))
|
|
634
|
+
canvasElements.remove(oldest.key)
|
|
635
|
+
return oldest.value.firmwareId
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Legacy positioned text: identity is the rect, so repeated updates reuse the component.
|
|
639
|
+
override fun sendPositionedText(
|
|
640
|
+
text: String, x: Int, y: Int, width: Int, height: Int, borderWidth: Int, borderRadius: Int
|
|
641
|
+
) {
|
|
642
|
+
upsertText("text@$x,$y,${width}x$height,$borderWidth,$borderRadius", null,
|
|
643
|
+
text, x, y, width, height, borderWidth, borderRadius)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
override fun drawLayoutText(
|
|
647
|
+
text: String, x: Int, y: Int, width: Int, height: Int,
|
|
648
|
+
borderWidth: Int, borderRadius: Int, elementId: String, layoutId: String?
|
|
649
|
+
) {
|
|
650
|
+
upsertText(elementId, layoutId, text, x, y, width, height, borderWidth, borderRadius)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private fun upsertText(
|
|
654
|
+
elementId: String, layoutId: String?, text: String,
|
|
655
|
+
x: Int, y: Int, width: Int, height: Int, borderWidth: Int, borderRadius: Int,
|
|
656
|
+
ack: Boolean = true
|
|
657
|
+
) {
|
|
658
|
+
ensureLayout(layoutId)
|
|
659
|
+
val rect = "$x,$y,${width}x$height,$borderWidth,$borderRadius"
|
|
660
|
+
val existing = canvasElements[elementId]
|
|
661
|
+
if (existing != null && !existing.isBitmap) {
|
|
662
|
+
if (existing.rect != rect) {
|
|
663
|
+
// Geometry moved/resized — recreate the box at the SAME firmware id, then set text.
|
|
664
|
+
sendProtobuf(NexProtobufUtils.generateCanvasCreateTextboxCommandBytes(
|
|
665
|
+
existing.firmwareId, x, y, width, height, borderWidth, borderRadius), ack = ack)
|
|
666
|
+
canvasElements[elementId] = existing.copy(rect = rect)
|
|
543
667
|
}
|
|
544
|
-
|
|
668
|
+
sendProtobuf(NexProtobufUtils.generateCanvasUpdateTextCommandBytes(existing.firmwareId, text), ack = ack)
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
val fid = allocFirmwareId(false)
|
|
672
|
+
if (fid == null) {
|
|
673
|
+
Bridge.log("Nex: text pool full (>6) — dropping element '$elementId'")
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
canvasElements[elementId] = CanvasElement(fid, false, rect)
|
|
677
|
+
sendProtobuf(NexProtobufUtils.generateCanvasCreateTextboxCommandBytes(
|
|
678
|
+
fid, x, y, width, height, borderWidth, borderRadius), ack = ack)
|
|
679
|
+
sendProtobuf(NexProtobufUtils.generateCanvasUpdateTextCommandBytes(fid, text), ack = ack)
|
|
680
|
+
}
|
|
545
681
|
|
|
546
|
-
|
|
547
|
-
|
|
682
|
+
override fun removeLayoutElement(elementId: String, layoutId: String?) {
|
|
683
|
+
val slot = canvasElements.remove(elementId) ?: return
|
|
684
|
+
sendProtobuf(NexProtobufUtils.generateCanvasDeleteComponentCommandBytes(slot.firmwareId))
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private data class NexBmp(val data: ByteArray, val w: Int, val h: Int)
|
|
548
688
|
|
|
689
|
+
/**
|
|
690
|
+
* Decode a base64 image to the glasses-native 1-bit BMP at the requested size. The panel is
|
|
691
|
+
* 1-bpp and renders white-on-black, so we scale to the rect, invert, then Floyd–Steinberg dither
|
|
692
|
+
* (preserving gradients as dot patterns instead of a hard 50% threshold) before the 1-bit encode.
|
|
693
|
+
*/
|
|
694
|
+
private fun decodeBitmapForNex(base64ImageData: String, width: Int?, height: Int?): NexBmp? {
|
|
695
|
+
return try {
|
|
696
|
+
val bmpData = android.util.Base64.decode(base64ImageData, android.util.Base64.DEFAULT)
|
|
697
|
+
if (bmpData == null || bmpData.isEmpty()) {
|
|
698
|
+
Log.e(TAG, "Failed to decode base64 image data")
|
|
699
|
+
return null
|
|
700
|
+
}
|
|
701
|
+
val bmp = BitmapFactory.decodeByteArray(bmpData, 0, bmpData.size)
|
|
702
|
+
val destW = width ?: bmp.width
|
|
703
|
+
val destH = height ?: bmp.height
|
|
704
|
+
val scaled = Bitmap.createScaledBitmap(bmp, destW, destH, true)
|
|
705
|
+
val inverted = invertBitmap(scaled)
|
|
706
|
+
val dithered = floydSteinbergDither(inverted)
|
|
707
|
+
NexBmp(BitmapJavaUtils.convertBitmapTo1BitBmpBytes(dithered, false), destW, destH)
|
|
549
708
|
} catch (e: Exception) {
|
|
550
|
-
Log.e(TAG, "Error
|
|
551
|
-
|
|
709
|
+
Log.e(TAG, "Error decoding bitmap: " + e.message)
|
|
710
|
+
null
|
|
552
711
|
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Legacy one-off bitmap: identity is the rect (no layout scene).
|
|
715
|
+
override fun displayBitmap(
|
|
716
|
+
base64ImageData: String, x: Int?, y: Int?, width: Int?, height: Int?
|
|
717
|
+
): Boolean {
|
|
718
|
+
val nb = decodeBitmapForNex(base64ImageData, width, height) ?: return false
|
|
719
|
+
upsertBitmap("bmp@${x ?: 0},${y ?: 0},${nb.w}x${nb.h}", null, nb.data, x ?: 0, y ?: 0, nb.w, nb.h)
|
|
720
|
+
return true
|
|
721
|
+
}
|
|
553
722
|
|
|
723
|
+
override fun drawLayoutBitmap(
|
|
724
|
+
base64ImageData: String, x: Int, y: Int, width: Int, height: Int,
|
|
725
|
+
elementId: String, layoutId: String?
|
|
726
|
+
): Boolean {
|
|
727
|
+
val nb = decodeBitmapForNex(base64ImageData, width, height) ?: return false
|
|
728
|
+
upsertBitmap(elementId, layoutId, nb.data, x, y, nb.w, nb.h)
|
|
729
|
+
return true
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private fun upsertBitmap(
|
|
733
|
+
elementId: String, layoutId: String?, bmpData: ByteArray, x: Int, y: Int, w: Int, h: Int
|
|
734
|
+
) {
|
|
735
|
+
ensureLayout(layoutId)
|
|
736
|
+
val rect = "$x,$y,${w}x$h"
|
|
737
|
+
val existing = canvasElements[elementId]
|
|
738
|
+
val fid: Int
|
|
739
|
+
if (existing != null && existing.isBitmap) {
|
|
740
|
+
fid = existing.firmwareId
|
|
741
|
+
if (existing.rect != rect) {
|
|
742
|
+
sendProtobuf(NexProtobufUtils.generateCanvasCreateBitmapCommandBytes(fid, x, y, w, h))
|
|
743
|
+
canvasElements[elementId] = existing.copy(rect = rect)
|
|
744
|
+
}
|
|
745
|
+
} else {
|
|
746
|
+
val alloc = allocFirmwareId(true)
|
|
747
|
+
if (alloc == null) {
|
|
748
|
+
Bridge.log("Nex: bitmap pool full (>4) — dropping element '$elementId'")
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
fid = alloc
|
|
752
|
+
canvasElements[elementId] = CanvasElement(fid, true, rect)
|
|
753
|
+
sendProtobuf(NexProtobufUtils.generateCanvasCreateBitmapCommandBytes(fid, x, y, w, h))
|
|
754
|
+
}
|
|
755
|
+
streamBitmapPixels(fid, bmpData)
|
|
554
756
|
}
|
|
555
757
|
override fun showDashboard() {
|
|
556
758
|
exit()
|
|
@@ -653,7 +855,14 @@ class MentraNex : SGCManager() {
|
|
|
653
855
|
private fun handleDisconnection(gatt: BluetoothGatt) {
|
|
654
856
|
Bridge.log("glass disconnected, stopping heartbeats")
|
|
655
857
|
Bridge.log("Entering STATE_DISCONNECTED branch")
|
|
656
|
-
|
|
858
|
+
|
|
859
|
+
// The glasses lose their canvas on disconnect — forget the
|
|
860
|
+
// component registry so post-reconnect frames take the CREATE
|
|
861
|
+
// path (firmware silently drops updates to dead component ids).
|
|
862
|
+
// The host replays the current scene after reconnect.
|
|
863
|
+
canvasElements.clear()
|
|
864
|
+
currentLayoutId = null
|
|
865
|
+
|
|
657
866
|
// Save current microphone state before disconnection
|
|
658
867
|
microphoneStateBeforeDisconnection = isMicrophoneEnabled
|
|
659
868
|
Bridge.log("Saved microphone state before disconnection: $microphoneStateBeforeDisconnection")
|
|
@@ -691,6 +900,12 @@ class MentraNex : SGCManager() {
|
|
|
691
900
|
}
|
|
692
901
|
|
|
693
902
|
private fun handleConnectionFailure(gatt: BluetoothGatt, status: Int) {
|
|
903
|
+
// Same as handleDisconnection: the glasses lose their canvas —
|
|
904
|
+
// forget the component registry so post-reconnect frames take
|
|
905
|
+
// the CREATE path (fw silently drops updates to dead ids).
|
|
906
|
+
canvasElements.clear()
|
|
907
|
+
currentLayoutId = null
|
|
908
|
+
|
|
694
909
|
// Save current microphone state before connection failure
|
|
695
910
|
microphoneStateBeforeDisconnection = isMicrophoneEnabled
|
|
696
911
|
Bridge.log("Saved microphone state before connection failure: $microphoneStateBeforeDisconnection")
|
|
@@ -870,7 +1085,7 @@ class MentraNex : SGCManager() {
|
|
|
870
1085
|
Bridge.log("Nex: 📤 Requesting MTU: $MTU_517 bytes")
|
|
871
1086
|
gatt.requestMtu(MTU_517) // Request our maximum MTU
|
|
872
1087
|
|
|
873
|
-
val uartService: BluetoothGattService = gatt.getService(NexBluetoothConstants.MAIN_SERVICE_UUID)
|
|
1088
|
+
val uartService: BluetoothGattService? = gatt.getService(NexBluetoothConstants.MAIN_SERVICE_UUID)
|
|
874
1089
|
|
|
875
1090
|
if (uartService != null) {
|
|
876
1091
|
val writeChar = uartService.getCharacteristic(NexBluetoothConstants.WRITE_CHAR_UUID)
|
|
@@ -878,17 +1093,13 @@ class MentraNex : SGCManager() {
|
|
|
878
1093
|
|
|
879
1094
|
writeChar?.let {
|
|
880
1095
|
mainWriteChar = it
|
|
881
|
-
// Write
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
// interval
|
|
886
|
-
//
|
|
887
|
-
|
|
888
|
-
// it here. (Previously this was set on the notify char in
|
|
889
|
-
// enableNotification(), where it had no effect on writes.)
|
|
890
|
-
it.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
891
|
-
Bridge.log("Nex: glass TX characteristic found (write type NO_RESPONSE)")
|
|
1096
|
+
// Write type is now chosen PER FRAGMENT in processQueue (acked for
|
|
1097
|
+
// everything except captions), so it's set there right before each
|
|
1098
|
+
// write rather than once here. Captions still use write-without-
|
|
1099
|
+
// response — which unblocks TX from the ~1-packet-per-connection-
|
|
1100
|
+
// interval ATT round-trip when the phone sleeps and the interval is
|
|
1101
|
+
// relaxed — while every other command is acked for reliable delivery.
|
|
1102
|
+
Bridge.log("Nex: glass TX characteristic found (write type set per-fragment)")
|
|
892
1103
|
}
|
|
893
1104
|
|
|
894
1105
|
notifyChar?.let {
|
|
@@ -1248,37 +1459,93 @@ class MentraNex : SGCManager() {
|
|
|
1248
1459
|
}
|
|
1249
1460
|
}
|
|
1250
1461
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1462
|
+
/**
|
|
1463
|
+
* Returns a colour-inverted copy of [src] (white <-> black). The firmware decoder normalises
|
|
1464
|
+
* BMP palette polarity, so the only way to flip the on-glass result from the SGC is to invert
|
|
1465
|
+
* the actual pixel content here.
|
|
1466
|
+
*/
|
|
1467
|
+
private fun invertBitmap(src: Bitmap): Bitmap {
|
|
1468
|
+
val out = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888)
|
|
1469
|
+
val paint = Paint().apply {
|
|
1470
|
+
colorFilter = ColorMatrixColorFilter(
|
|
1471
|
+
ColorMatrix(
|
|
1472
|
+
floatArrayOf(
|
|
1473
|
+
-1f, 0f, 0f, 0f, 255f,
|
|
1474
|
+
0f, -1f, 0f, 0f, 255f,
|
|
1475
|
+
0f, 0f, -1f, 0f, 255f,
|
|
1476
|
+
0f, 0f, 0f, 1f, 0f
|
|
1477
|
+
)
|
|
1478
|
+
)
|
|
1479
|
+
)
|
|
1480
|
+
}
|
|
1481
|
+
Canvas(out).drawBitmap(src, 0f, 0f, paint)
|
|
1482
|
+
return out
|
|
1483
|
+
}
|
|
1253
1484
|
|
|
1485
|
+
/**
|
|
1486
|
+
* Floyd-Steinberg error-diffusion dithering to monochrome. Returns a copy of [src] whose pixels
|
|
1487
|
+
* are pure black or white but whose dot density approximates the original luminance, preserving
|
|
1488
|
+
* far more perceived detail on a 1-bpp panel than a flat threshold. Luminance uses Rec. 601
|
|
1489
|
+
* weights; quantization error is pushed to the right/below neighbours (7/3/5/1 over 16).
|
|
1490
|
+
*/
|
|
1491
|
+
private fun floydSteinbergDither(src: Bitmap): Bitmap {
|
|
1492
|
+
val w = src.width
|
|
1493
|
+
val h = src.height
|
|
1494
|
+
val px = IntArray(w * h)
|
|
1495
|
+
src.getPixels(px, 0, w, 0, 0, w, h)
|
|
1496
|
+
|
|
1497
|
+
// Working luminance buffer (float so diffused error can over/undershoot 0..255).
|
|
1498
|
+
val lum = FloatArray(w * h)
|
|
1499
|
+
for (i in 0 until w * h) {
|
|
1500
|
+
val c = px[i]
|
|
1501
|
+
val r = (c shr 16) and 0xFF
|
|
1502
|
+
val g = (c shr 8) and 0xFF
|
|
1503
|
+
val b = c and 0xFF
|
|
1504
|
+
lum[i] = 0.299f * r + 0.587f * g + 0.114f * b
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
for (y in 0 until h) {
|
|
1508
|
+
for (x in 0 until w) {
|
|
1509
|
+
val idx = y * w + x
|
|
1510
|
+
val old = lum[idx]
|
|
1511
|
+
val newVal = if (old < 128f) 0f else 255f
|
|
1512
|
+
val err = old - newVal
|
|
1513
|
+
px[idx] = if (newVal >= 128f) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
|
|
1514
|
+
|
|
1515
|
+
if (x + 1 < w) lum[idx + 1] += err * 7f / 16f
|
|
1516
|
+
if (y + 1 < h) {
|
|
1517
|
+
if (x - 1 >= 0) lum[idx + w - 1] += err * 3f / 16f
|
|
1518
|
+
lum[idx + w] += err * 5f / 16f
|
|
1519
|
+
if (x + 1 < w) lum[idx + w + 1] += err * 1f / 16f
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
1525
|
+
out.setPixels(px, 0, w, 0, 0, w, h)
|
|
1526
|
+
return out
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** Stream 1-bit BMP pixels into an already-created canvas bitmap component [componentId]. */
|
|
1530
|
+
private fun streamBitmapPixels(componentId: Int, bmpData: ByteArray) {
|
|
1254
1531
|
try {
|
|
1255
1532
|
if (bmpData.isEmpty()) {
|
|
1256
|
-
Log.e(TAG,"Invalid BMP data provided")
|
|
1533
|
+
Log.e(TAG, "Invalid BMP data provided")
|
|
1257
1534
|
return
|
|
1258
1535
|
}
|
|
1259
|
-
|
|
1260
|
-
Bridge.log("Processing BMP data, size: ${bmpData.size} bytes")
|
|
1261
|
-
|
|
1262
|
-
// Generate proper 2-byte hex stream ID (e.g., "002A") as per protobuf specification
|
|
1536
|
+
// 4-digit hex stream id per the canvas image protocol.
|
|
1263
1537
|
val totalChunks = (bmpData.size + bmpChunkSize - 1) / bmpChunkSize
|
|
1264
|
-
val streamId = "%04X".format(random.nextInt(0x10000))
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1538
|
+
val streamId = "%04X".format(random.nextInt(0x10000))
|
|
1539
|
+
|
|
1540
|
+
sendProtobuf(NexProtobufUtils.generateCanvasUpdateImageCommandBytes(
|
|
1541
|
+
componentId, streamId, totalChunks))
|
|
1268
1542
|
|
|
1269
|
-
// Send all chunks with proper stream ID parsing
|
|
1270
1543
|
val chunks = NexProtobufUtils.createBmpChunksForNexGlasses(streamId, bmpData, totalChunks, bmpChunkSize)
|
|
1271
1544
|
currentImageChunks.clear()
|
|
1272
1545
|
currentImageChunks.addAll(chunks)
|
|
1273
1546
|
sendDataSequentially(chunks)
|
|
1274
|
-
|
|
1275
|
-
// Note: The following are commented out in the original
|
|
1276
|
-
// sendBmpEndCommand()
|
|
1277
|
-
// sendBmpCRC(bmpData)
|
|
1278
|
-
// lastThingDisplayedWasAnImage = true
|
|
1279
|
-
|
|
1280
1547
|
} catch (e: Exception) {
|
|
1281
|
-
Log.e(TAG,"Error
|
|
1548
|
+
Log.e(TAG, "Error streaming bitmap pixels: ${e.message}")
|
|
1282
1549
|
}
|
|
1283
1550
|
}
|
|
1284
1551
|
|
|
@@ -1486,6 +1753,19 @@ class MentraNex : SGCManager() {
|
|
|
1486
1753
|
// EventBus.getDefault().post(GlassesHeadDownEvent())
|
|
1487
1754
|
// EventBus.getDefault().post(GlassesTapOutputEvent(2, isRight, System.currentTimeMillis()))
|
|
1488
1755
|
}
|
|
1756
|
+
GlassesToPhone.PayloadCase.CANVAS_RESULT -> {
|
|
1757
|
+
// Ack for CanvasCreateComponent / CanvasClear (updates are
|
|
1758
|
+
// unacked). Firmware codes: OK / INVALID (bad id for type) /
|
|
1759
|
+
// OVERSIZE (bitmap > 200x200) / OOM (no backing storage).
|
|
1760
|
+
// Consumed for observability — a silent blank screen becomes
|
|
1761
|
+
// a diagnosable log line. Non-OK codes also invalidate the
|
|
1762
|
+
// registry entry so the next frame recreates the component.
|
|
1763
|
+
val canvasResult = glassesToPhone.canvasResult
|
|
1764
|
+
if (canvasResult.code != mentraos.ble.MentraosBle.CanvasResult.ResultCode.OK) {
|
|
1765
|
+
Bridge.log("Nex: CANVAS_RESULT id=${canvasResult.id} code=${canvasResult.code} — dropping registry entry for recreate")
|
|
1766
|
+
canvasElements.entries.removeAll { it.value.firmwareId == canvasResult.id.toInt() }
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1489
1769
|
GlassesToPhone.PayloadCase.PAYLOAD_NOT_SET,
|
|
1490
1770
|
null -> {
|
|
1491
1771
|
// Do nothing
|
|
@@ -1594,8 +1874,9 @@ class MentraNex : SGCManager() {
|
|
|
1594
1874
|
*
|
|
1595
1875
|
* @param protobufBytes raw serialized PhoneToGlasses bytes (no framing)
|
|
1596
1876
|
* @param waitTime optional post-message delay in ms applied after the last fragment (-1 = none)
|
|
1877
|
+
* @param ack when true (default) each fragment is a write-with-response; captions pass false
|
|
1597
1878
|
*/
|
|
1598
|
-
private fun sendProtobuf(protobufBytes: ByteArray, waitTime: Int = -1) {
|
|
1879
|
+
private fun sendProtobuf(protobufBytes: ByteArray, waitTime: Int = -1, ack: Boolean = true) {
|
|
1599
1880
|
val headerSize = 4 // [0x02][seq][totalChunks][chunkIndex]
|
|
1600
1881
|
val maxFragment = (maxChunkSize - headerSize).coerceAtLeast(1)
|
|
1601
1882
|
val totalChunks =
|
|
@@ -1622,7 +1903,7 @@ class MentraNex : SGCManager() {
|
|
|
1622
1903
|
System.arraycopy(protobufBytes, start, frame, headerSize, fragLen)
|
|
1623
1904
|
}
|
|
1624
1905
|
// Carry the post-message delay on the final fragment only.
|
|
1625
|
-
SendRequest(frame, if (index == totalChunks - 1) waitTime else -1)
|
|
1906
|
+
SendRequest(frame, if (index == totalChunks - 1) waitTime else -1, ack)
|
|
1626
1907
|
}
|
|
1627
1908
|
|
|
1628
1909
|
sendQueue.offer(frames)
|
|
@@ -1665,6 +1946,15 @@ class MentraNex : SGCManager() {
|
|
|
1665
1946
|
var writeStarted = false
|
|
1666
1947
|
if (canSend) {
|
|
1667
1948
|
mainWaiter.setTrue()
|
|
1949
|
+
// Per-request write type: acked fragments go out WITH response
|
|
1950
|
+
// (firmware ATT-acks each), captions go out WITHOUT response for
|
|
1951
|
+
// throughput. Set on the shared char just before the write — the
|
|
1952
|
+
// worker is single-threaded so there's no concurrent write to race.
|
|
1953
|
+
mainWriteChar?.writeType = if (request.ack) {
|
|
1954
|
+
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
|
1955
|
+
} else {
|
|
1956
|
+
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
1957
|
+
}
|
|
1668
1958
|
mainWriteChar?.value = request.data
|
|
1669
1959
|
// Honor the return value: writeCharacteristic returns false when the
|
|
1670
1960
|
// stack is busy / out of buffers, and in that case no callback fires.
|
|
@@ -1683,7 +1973,11 @@ class MentraNex : SGCManager() {
|
|
|
1683
1973
|
}
|
|
1684
1974
|
|
|
1685
1975
|
if (writeStarted) {
|
|
1686
|
-
|
|
1976
|
+
// Acked writes need to wait for the real ATT response (a
|
|
1977
|
+
// connection interval or two); no-response writes get the
|
|
1978
|
+
// near-instant fast-path bound.
|
|
1979
|
+
val timeout = if (request.ack) WRITE_ACK_TIMEOUT_MS else WRITE_CALLBACK_TIMEOUT_MS
|
|
1980
|
+
mainWaiter.waitWhileTrue(timeout)
|
|
1687
1981
|
}
|
|
1688
1982
|
|
|
1689
1983
|
|