@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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
  4. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
  5. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  13. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  22. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
  23. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  28. package/build/BluetoothSdk.types.d.ts +12 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/types/index.d.ts +3 -0
  32. package/build/types/index.d.ts.map +1 -0
  33. package/build/types/index.js +2 -0
  34. package/build/types/index.js.map +1 -0
  35. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  36. package/ios/Source/DeviceManager.swift +134 -29
  37. package/ios/Source/DeviceStore.swift +0 -5
  38. package/ios/Source/MentraBluetoothSDK.swift +26 -23
  39. package/ios/Source/OtaManifest.swift +1 -1
  40. package/ios/Source/sgcs/G2.swift +518 -11
  41. package/ios/Source/sgcs/MentraLive.swift +308 -30
  42. package/ios/Source/sgcs/MentraNex.swift +416 -32
  43. package/ios/Source/sgcs/SGCManager.swift +155 -0
  44. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  45. package/ios/Source/status/DeviceStatus.swift +0 -8
  46. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  47. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  48. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  49. package/ios/Source/utils/MessageChunker.swift +73 -0
  50. package/package.json +14 -7
  51. package/src/BluetoothSdk.types.ts +13 -1
  52. 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
- private data class SendRequest( val data: ByteArray, var waitTime: Int = -1) {
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() - sending clear display request command bytes");
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
- // sendTextWall(" ")
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
- val textChunks: ByteArray = createTextWallChunksForNex(text)
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
- val textChunks: ByteArray = createTextWallChunksForNex(text)
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 = buildString {
523
- top?.let { append(it) }
524
- top?.let { append("\n") }
525
- bottom?.let { append(it) }
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
- override fun displayBitmap(
532
- base64ImageData: String,
533
- x: Int?,
534
- y: Int?,
535
- width: Int?,
536
- height: Int?
537
- ): Boolean {
538
- try {
539
- val bmpData: ByteArray? = android.util.Base64.decode(base64ImageData, android.util.Base64.DEFAULT)
540
- if (bmpData == null || bmpData.isEmpty()) {
541
- Log.e(TAG, "Failed to decode base64 image data");
542
- return false;
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
- val bmp = BitmapFactory.decodeByteArray(bmpData, 0, bmpData.size)
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
- displayBitmapImageForNexGlasses(bmpData, bmp.width, bmp.height)
547
- return true
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 in displaying bitmap: " + e.message);
551
- return false
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-without-response on the characteristic we actually write
882
- // to (the firmware RX char advertises WRITE_WITHOUT_RESP). This
883
- // is what unblocks TX from the ~1-packet-per-connection-interval
884
- // ATT round-trip critical when the phone sleeps and the
885
- // interval is relaxed. Android defaults a freshly-discovered
886
- // characteristic to WRITE_TYPE_DEFAULT (with response), and the
887
- // reference is reassigned on every service rediscovery, so set
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
+ // responsewhich 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
- private fun displayBitmapImageForNexGlasses(bmpData: ByteArray, width: Int, height: Int) {
1252
- Bridge.log("Starting BMP display process for ${width}x$height image")
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)) // 4-digit hex format
1265
-
1266
- val startImageSendingBytes = NexProtobufUtils.generateDisplayImageCommandBytes(streamId, totalChunks, width, height)
1267
- sendProtobuf(startImageSendingBytes)
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 in displayBitmapImage: ${e.message}")
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
- mainWaiter.waitWhileTrue(WRITE_CALLBACK_TIMEOUT_MS)
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