@mentra/bluetooth-sdk 3.2.1-dev.311 → 3.2.1

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 (133) hide show
  1. package/README.md +4 -4
  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 +6 -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 +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  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/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -18,7 +18,6 @@ import android.media.MediaFormat
18
18
  import android.os.Handler
19
19
  import android.os.HandlerThread
20
20
  import android.os.Looper
21
- import android.util.Base64
22
21
  import com.mentra.bluetoothsdk.Bridge
23
22
  import com.mentra.bluetoothsdk.DeviceManager
24
23
  import com.mentra.bluetoothsdk.DeviceStore
@@ -31,7 +30,9 @@ import java.nio.ByteOrder
31
30
  import java.util.Calendar
32
31
  import java.util.TimeZone
33
32
  import java.util.UUID
34
- import java.util.zip.Deflater
33
+ import kotlinx.coroutines.Dispatchers
34
+ import kotlinx.coroutines.runBlocking
35
+ import kotlinx.coroutines.withContext
35
36
 
36
37
  // ---------- Nimo BLE Constants ----------
37
38
 
@@ -68,6 +69,7 @@ internal object NimoProtocol {
68
69
  const val CMD_INSTRUCTION_REPORT = 0x06
69
70
  const val CMD_CONTROL_INSTRUCTION = 0x07
70
71
  const val CMD_CONTROL_FACTORY = 0x08
72
+ const val FACTORY_RECOVER = 0x03
71
73
  const val CMD_CONTROL_NOTIFICATION = 0x09
72
74
 
73
75
  // get parameter keys
@@ -88,6 +90,9 @@ internal object NimoProtocol {
88
90
  const val SET_DISPLAY_OFF = 0x0F
89
91
  const val SET_PHONE_TYPE = 0x14
90
92
  const val SET_HEIGHT_LEVEL = 0x17
93
+ // Dynamic-v1 firmware: 1-based UI locale; 1 selects English.
94
+ const val SET_SYSTEM_LANGUAGE = 0x24
95
+ const val LANGUAGE_ENGLISH = 0x01
91
96
 
92
97
  // control instruction keys
93
98
  const val CTRL_ENTER_APP = 0x01
@@ -877,12 +882,28 @@ private class NimoReconnectionManager(
877
882
  // ---------- Nimo Class ----------
878
883
 
879
884
  class Nimo : SGCManager() {
885
+ override val sceneHandoffRequiresClear = false
886
+ // A legacy text confirmation replaces the active canvas; brightness itself does not.
887
+ override val showBrightnessConfirmation = false
888
+ // The host replays its scene on connection; a later welcome clear would erase it.
889
+ override val showConnectionConfirmation = false
880
890
 
881
891
  companion object {
882
892
  private const val PREFS_NAME = "NimoPrefs"
883
893
  private const val KEY_LAST_ADDRESS = "nimo_lastDeviceAddress"
884
894
  private const val KEY_LAST_NAME = "nimo_lastDeviceName"
885
895
 
896
+ internal fun removeBluetoothBond(address: String?) {
897
+ if (address.isNullOrBlank()) return
898
+ try {
899
+ val device = BluetoothAdapter.getDefaultAdapter()?.getRemoteDevice(address) ?: return
900
+ val removed = device.javaClass.getMethod("removeBond").invoke(device) as? Boolean
901
+ Bridge.log("NIMO: Bluetooth unbond requested=$removed")
902
+ } catch (error: Exception) {
903
+ Bridge.log("NIMO: Bluetooth unbond failed: ${error.message}")
904
+ }
905
+ }
906
+
886
907
  private const val TWS_TIMEOUT_MS = 10_000L
887
908
  private const val ACK_TIMEOUT_MS = 5_000L
888
909
  // Generous: the glasses' classic bond can take 30-45s PER attempt and may need
@@ -898,8 +919,6 @@ class Nimo : SGCManager() {
898
919
  private const val MAX_BOND_ATTEMPTS = 3
899
920
  private const val BOND_RETRY_DELAY_MS = 2_000L
900
921
  private const val BATTERY_POLL_MS = 30_000L
901
- private const val TEXT_QUEUE_TICK_MS = 100L
902
- private const val WRITE_WATCHDOG_MS = 1_000L
903
922
  private const val SET_TIME_MAX_ATTEMPTS = 3
904
923
  private const val SET_TIME_RETRY_DELAY_MS = 500L
905
924
  }
@@ -909,12 +928,25 @@ class Nimo : SGCManager() {
909
928
  hasMic = true
910
929
  }
911
930
 
912
- // The text surface every sendTextWall renders into. The ASR note view (appId 0x04,
913
- // note text resId 0x00) is a plain text page that renders pushed text directly;
914
- // Prompter (0x06) was tried first but did not display pushed text on hardware.
915
- private val textAppId = NimoProtocol.APP_ID_ASR_NOTE
916
- private val textLayoutId = 0
917
- private val textResId = 0
931
+ private val mainHandler = Handler(Looper.getMainLooper())
932
+ private val mainScheduler = handlerScheduler(mainHandler)
933
+ // Variant-specific: release has no receiver or framebuffer protocol implementation.
934
+ private var diagnostics: NimoDiagnostics? = null
935
+ private val canvasEncoderThread = HandlerThread("NimoCanvasEncoder").apply { start() }
936
+ private val canvasEncoder = NimoCanvasEncoder(mainScheduler,
937
+ handlerScheduler(Handler(canvasEncoderThread.looper)),
938
+ { canvasEncoderThread.quitSafely() },
939
+ { Bridge.log("NIMO: canvas validation/encoding failed; retaining last valid scene: ${it.message}") })
940
+ private val canvas = NimoCanvasCoordinator(mainScheduler,
941
+ { (negotiatedMtu - 3).coerceIn(20, 512) }, ::enqueueFrames, ::abortTransport,
942
+ { Bridge.log("NIMO: canvas rejected status=$it; not a render ACK") },
943
+ { diagnostics?.cancelHeldCapture("Canvas scope takeover preempted held capture", resumeCanvas = false) })
944
+
945
+ private fun handlerScheduler(handler: Handler) = NimoScheduler { delay, task ->
946
+ val runnable = Runnable(task)
947
+ handler.postDelayed(runnable, delay)
948
+ ({ handler.removeCallbacks(runnable) })
949
+ }
918
950
 
919
951
  // BLE
920
952
  private val context: Context
@@ -926,7 +958,6 @@ class Nimo : SGCManager() {
926
958
  private var micChar: BluetoothGattCharacteristic? = null
927
959
  private var isDisconnecting = false
928
960
  private var negotiatedMtu = 23
929
-
930
961
  // Device search
931
962
  private var DEVICE_SEARCH_ID = "NOT_SET"
932
963
 
@@ -954,7 +985,6 @@ class Nimo : SGCManager() {
954
985
 
955
986
  private val reconnectionManager = NimoReconnectionManager()
956
987
  private val receiveAssembler = NimoReceiveAssembler()
957
- private val mainHandler = Handler(Looper.getMainLooper())
958
988
 
959
989
  // Handshake
960
990
  private enum class HandshakeState {
@@ -965,6 +995,7 @@ class Nimo : SGCManager() {
965
995
  }
966
996
  private var handshakeState = HandshakeState.IDLE
967
997
  private var twsConnected = false
998
+ private var peerCompanionReady: Boolean? = null
968
999
  private var twsTimeoutRunnable: Runnable? = null
969
1000
  private var pairingTimeoutRunnable: Runnable? = null
970
1001
 
@@ -972,20 +1003,16 @@ class Nimo : SGCManager() {
972
1003
  private class PendingAck(val onResult: (Boolean) -> Unit, val timeout: Runnable)
973
1004
  private val pendingAcks = mutableMapOf<Int, PendingAck>()
974
1005
 
975
- // Serialized write queue: Android allows one in-flight GATT op; WRITE_TYPE_DEFAULT also
976
- // gives us long-writes when a 509-byte frame exceeds the negotiated MTU.
977
- private class QueuedWrite(val char: BluetoothGattCharacteristic, val bytes: ByteArray)
978
- private val writeQueue = ArrayDeque<QueuedWrite>()
979
- private var writeInFlight = false
980
- private var writeWatchdog: Runnable? = null
1006
+ // One queue owns characteristic writes and CCCD callbacks, including non-canvas commands.
1007
+ private val writes = NimoGattQueue<BluetoothGattCharacteristic>(mainScheduler,
1008
+ { characteristic, bytes ->
1009
+ characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
1010
+ characteristic.value = bytes
1011
+ gatt?.writeCharacteristic(characteristic) ?: false
1012
+ }, ::abortTransport, NimoBLE.INTER_FRAME_DELAY_MS)
981
1013
 
982
- // Heartbeat / text queue
1014
+ // Heartbeat
983
1015
  private var batteryPollRunnable: Runnable? = null
984
- private var textQueueRunnable: Runnable? = null
985
- private var pendingText: String? = null
986
- private var textAppEntered = false
987
- private var navAppEntered = false
988
- private var currentGlassesAppId = -1
989
1016
 
990
1017
  // Battery
991
1018
  private var lastBatteryLevel = -1
@@ -1039,7 +1066,7 @@ class Nimo : SGCManager() {
1039
1066
  Bridge.log("NIMO: target is bonded — connecting directly (no discovery)")
1040
1067
  lastDeviceName = target.name
1041
1068
  lastDeviceAddress = target.address
1042
- mainHandler.post { connectGattLe(target) }
1069
+ mainHandler.post { connectCompanionGatt(target) }
1043
1070
  return true
1044
1071
  }
1045
1072
 
@@ -1056,6 +1083,12 @@ class Nimo : SGCManager() {
1056
1083
  }
1057
1084
 
1058
1085
  override fun disconnect() {
1086
+ if (Looper.myLooper() != mainHandler.looper) {
1087
+ // The manager can discard or replace this communicator immediately on return.
1088
+ // Finish local teardown on its owning looper before releasing that caller.
1089
+ runBlocking { withContext(Dispatchers.Main) { disconnect() } }
1090
+ return
1091
+ }
1059
1092
  Bridge.log("NIMO: disconnect()")
1060
1093
  isDisconnecting = true
1061
1094
  cancelPairingTimeout()
@@ -1079,7 +1112,36 @@ class Nimo : SGCManager() {
1079
1112
  DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1080
1113
  }
1081
1114
 
1115
+ /** Explicit Unpair only. Ordinary forget/cleanup must never reset the glasses. */
1116
+ internal fun resetForUnpair(onResult: (Boolean) -> Unit) {
1117
+ check(Looper.myLooper() == mainHandler.looper)
1118
+ if (gatt == null || txChar == null || handshakeState != HandshakeState.READY) {
1119
+ Bridge.log("NIMO: unpair while offline; remote reset unavailable")
1120
+ onResult(false)
1121
+ return
1122
+ }
1123
+ isDisconnecting = true
1124
+ stopTimers()
1125
+ reconnectionManager.stop()
1126
+ canvasEncoder.invalidate()
1127
+ canvas.disconnected()
1128
+ Bridge.log("NIMO: sending factory reset for explicit Unpair")
1129
+ sendAwaitingAck(NimoProtocol.CMD_CONTROL_FACTORY, NimoProtocol.FACTORY_RECOVER, byteArrayOf()) {
1130
+ Bridge.log("NIMO: factory reset acknowledged=$it")
1131
+ onResult(it)
1132
+ }
1133
+ }
1134
+
1135
+ internal fun removeBluetoothBond() {
1136
+ val address = gatt?.device?.address ?: lastDeviceAddress ?: return
1137
+ removeBluetoothBond(address)
1138
+ }
1139
+
1082
1140
  override fun forget() {
1141
+ if (Looper.myLooper() != mainHandler.looper) {
1142
+ runBlocking { withContext(Dispatchers.Main) { forget() } }
1143
+ return
1144
+ }
1083
1145
  Bridge.log("NIMO: forget()")
1084
1146
  disconnect()
1085
1147
  lastDeviceAddress = null
@@ -1088,7 +1150,14 @@ class Nimo : SGCManager() {
1088
1150
  }
1089
1151
 
1090
1152
  override fun cleanup() {
1153
+ if (Looper.myLooper() != mainHandler.looper) {
1154
+ runBlocking { withContext(Dispatchers.Main) { cleanup() } }
1155
+ return
1156
+ }
1157
+ canvasEncoder.close()
1091
1158
  disconnect()
1159
+ diagnostics?.close()
1160
+ diagnostics = null
1092
1161
  audioClient.stop()
1093
1162
  }
1094
1163
 
@@ -1150,20 +1219,15 @@ class Nimo : SGCManager() {
1150
1219
  }
1151
1220
 
1152
1221
  override fun clearDisplay() {
1153
- // Keep the text app/page alive — a blank update avoids re-entering the app next time.
1154
- sendTextWall(" ")
1222
+ submitCanvas("legacy") { emptyList() }
1155
1223
  }
1156
1224
 
1157
1225
  override fun sendText(text: String) {
1158
- // Coalesced: only the most recent pending text survives until the next 100ms drain.
1159
- Bridge.log("NIMO: sendText(text=$text)")
1160
- pendingText = text
1226
+ sendTextWall(text)
1161
1227
  }
1162
1228
 
1163
1229
  override fun sendTextWall(text: String) {
1164
- // Coalesced: only the most recent pending text survives until the next 100ms drain.
1165
- Bridge.log("NIMO: sendTextWall(text=$text)")
1166
- pendingText = text
1230
+ submitCanvas("legacy") { canvasText(text, 0, 0, 500, 220, 0, 0) }
1167
1231
  }
1168
1232
 
1169
1233
  override fun sendDoubleTextWall(top: String, bottom: String) {
@@ -1179,12 +1243,9 @@ class Nimo : SGCManager() {
1179
1243
  borderWidth: Int,
1180
1244
  borderRadius: Int
1181
1245
  ) {
1182
- // Navigation pushes turn text via positioned_text. Nimo widgets have fixed geometry, so
1183
- // position/border are ignored — funnel the text through the same coalesced path as
1184
- // sendTextWall so it renders on the ASR note page. Without this override the base no-op
1185
- // silently dropped all navigation text.
1186
- // Bridge.log("NIMO: sendPositionedText(text=$text)")
1187
- // pendingText = text
1246
+ submitCanvas("legacy") {
1247
+ canvasText(text, x, y, width, height, borderWidth, borderRadius)
1248
+ }
1188
1249
  }
1189
1250
 
1190
1251
  override fun displayBitmap(
@@ -1194,57 +1255,18 @@ class Nimo : SGCManager() {
1194
1255
  width: Int?,
1195
1256
  height: Int?
1196
1257
  ): Boolean {
1197
- // Nimo widgets have fixed geometry; x/y are not honored. Renders into the navigation
1198
- // large-map widget (appId 0x01, resId 0x05, 452x170 2bpp) — the full-width nav map,
1199
- // which shows far more than the small 160x160 mini-map (resId 0x00). bitmapToGrayscale
1200
- // aspect-fits onto black, so a non-matching source aspect letterboxes rather than
1201
- // distorts. The nav app must be foregrounded before pushing content (mirrors the text
1202
- // path). TODO: hardware-verify the large-map widget renders and the nav app-mode.
1203
- val targetWidth = NimoProtocol.NAV_LARGE_MAP_WIDTH
1204
- val targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT
1205
- Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=$navAppEntered)")
1206
1258
  return try {
1207
- val imageBytes = Base64.decode(base64ImageData, Base64.DEFAULT)
1208
- val bitmap =
1209
- BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) ?: return false
1210
- val grayscale = bitmapToGrayscale(bitmap, targetWidth, targetHeight)
1211
- val packed = packL8To2bpp(grayscale)
1212
- val (payload, compression) = compressAdaptive(packed)
1213
- if (!navAppEntered) {
1214
- sendFrame(
1215
- NimoFrameCodec.encodeFrame(
1216
- NimoProtocol.CMD_CONTROL_INSTRUCTION,
1217
- NimoProtocol.CTRL_ENTER_APP,
1218
- byteArrayOf(
1219
- NimoProtocol.APP_ID_NAV.toByte(),
1220
- NimoProtocol.APP_MODE_STANDALONE.toByte()
1221
- )
1222
- )
1223
- )
1224
- // Optimistic; corrected by app-state reports if the glasses refuse/exit.
1225
- navAppEntered = true
1259
+ val left = x ?: 0; val top = y ?: 0
1260
+ val w = width ?: (NimoCanvasCodec.WIDTH - left)
1261
+ val h = height ?: (NimoCanvasCodec.HEIGHT - top)
1262
+ NimoCanvasCodec.region(left, top, w, h)
1263
+ // The Boolean acknowledges bounded local validation, never visible rendering.
1264
+ // Decode once now; the background encoder owns/recycles this same source.
1265
+ val source = decodeCanvasSource(base64ImageData)
1266
+ encodeCanvas("legacy", release = { source.recycle() }) {
1267
+ NimoCanvasCodec.replace(listOf(NimoCanvasCodec.bitmap(left, top, w, h,
1268
+ bitmapToGrayscale(source, w, h))))
1226
1269
  }
1227
- val content =
1228
- NimoFrameCodec.imageHeader(
1229
- width = targetWidth,
1230
- height = targetHeight,
1231
- formatBpp = NimoProtocol.FORMAT_2BPP,
1232
- compression = compression,
1233
- originalSize = packed.size,
1234
- compressedSize =
1235
- if (compression == NimoProtocol.COMPRESSION_NONE) 0
1236
- else payload.size
1237
- ) + payload
1238
- val frames =
1239
- NimoFrameCodec.updateContentFrames(
1240
- appId = NimoProtocol.APP_ID_NAV,
1241
- layoutId = 0,
1242
- resId = NimoProtocol.NAV_RES_LARGE_MAP,
1243
- resType = NimoProtocol.WIDGET_PICTURE,
1244
- content = content
1245
- )
1246
- enqueueFrames(frames)
1247
- true
1248
1270
  } catch (e: Exception) {
1249
1271
  Bridge.log("NIMO: displayBitmap failed: ${e.message}")
1250
1272
  false
@@ -1252,19 +1274,60 @@ class Nimo : SGCManager() {
1252
1274
  }
1253
1275
 
1254
1276
  override fun showDashboard() {
1255
- Bridge.log("NIMO: showDashboard()")
1256
- textAppEntered = false
1257
- navAppEntered = false
1258
- sendFrame(
1259
- NimoFrameCodec.encodeFrame(
1260
- NimoProtocol.CMD_CONTROL_INSTRUCTION,
1261
- NimoProtocol.CTRL_ENTER_APP,
1262
- byteArrayOf(
1263
- NimoProtocol.APP_ID_DASHBOARD.toByte(),
1264
- NimoProtocol.APP_MODE_STANDALONE.toByte()
1265
- )
1266
- )
1267
- )
1277
+ exit()
1278
+ }
1279
+
1280
+ override fun applySceneFrame(frame: SceneFrame) {
1281
+ // Serialize the complete ordered scene, not the host differ's annotations.
1282
+ encodeCanvas("${frame.appId}:${frame.epoch}", frame.replay) {
1283
+ NimoCanvasCodec.scene(frame.elements.map { element ->
1284
+ NimoCanvasCodec.Element(element.type, element.x, element.y, element.w, element.h,
1285
+ element.text, element.data, element.border, element.radius)
1286
+ }, ::decodeCanvasImage)
1287
+ }
1288
+ }
1289
+
1290
+ override fun clearSceneElements(elementIds: List<String>) {
1291
+ // Dynamic Layout replaces a complete scene; there is no persistent per-ID state.
1292
+ clearDisplay()
1293
+ }
1294
+
1295
+ private fun canvasText(text: String, x: Int, y: Int, width: Int, height: Int,
1296
+ border: Int, radius: Int): List<NimoCanvasCodec.Object> {
1297
+ require(border in 0..32 && radius in 0..255)
1298
+ val objects = mutableListOf<NimoCanvasCodec.Object>()
1299
+ if (border > 0) objects.add(NimoCanvasCodec.rectangle(x, y, width, height, border, radius))
1300
+ // Legacy callers may send an unwrapped paragraph. Scene IR uses textRows directly.
1301
+ if (text.isNotEmpty() && !text.contains('\n')) {
1302
+ objects.add(NimoCanvasCodec.label(text, x, y, width, height))
1303
+ } else objects.addAll(NimoCanvasCodec.textRows(text, x, y, width, height))
1304
+ return objects
1305
+ }
1306
+
1307
+ /** Keep only the latest pending encode; completion cannot overwrite a newer scene or Exit. */
1308
+ private fun submitCanvas(scope: String, force: Boolean = false,
1309
+ objects: () -> List<NimoCanvasCodec.Object>) {
1310
+ encodeCanvas(scope, force) { NimoCanvasCodec.replace(objects()) }
1311
+ }
1312
+
1313
+ private fun encodeCanvas(scope: String, force: Boolean = false, release: () -> Unit = {},
1314
+ encode: () -> ByteArray): Boolean =
1315
+ canvasEncoder.submit(encode, release) { bytes -> canvas.offer(bytes, scope, force) }
1316
+
1317
+ private fun decodeCanvasSource(data: String): Bitmap {
1318
+ val bytes = NimoCanvasCodec.imageBytes(data)
1319
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
1320
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
1321
+ require(bounds.outWidth in 1..4096 && bounds.outHeight in 1..4096 &&
1322
+ bounds.outWidth.toLong() * bounds.outHeight <= 4_000_000) { "Image decoded size exceeds limit" }
1323
+ return requireNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size)) { "Invalid image" }
1324
+ }
1325
+
1326
+ private fun decodeCanvasImage(data: String, width: Int, height: Int): ByteArray {
1327
+ val source = decodeCanvasSource(data)
1328
+ return try {
1329
+ bitmapToGrayscale(source, width, height)
1330
+ } finally { source.recycle() }
1268
1331
  }
1269
1332
 
1270
1333
  override fun setDashboardPosition(height: Int, depth: Int) {
@@ -1325,17 +1388,11 @@ class Nimo : SGCManager() {
1325
1388
  }
1326
1389
 
1327
1390
  override fun exit() {
1328
- Bridge.log("NIMO: exit()")
1329
- val appId = if (currentGlassesAppId >= 0) currentGlassesAppId else textAppId
1330
- textAppEntered = false
1331
- navAppEntered = false
1332
- sendFrame(
1333
- NimoFrameCodec.encodeFrame(
1334
- NimoProtocol.CMD_CONTROL_INSTRUCTION,
1335
- NimoProtocol.CTRL_QUIT_APP,
1336
- byteArrayOf(appId.toByte())
1337
- )
1338
- )
1391
+ canvasEncoder.invalidate()
1392
+ mainHandler.post {
1393
+ diagnostics?.cancelHeldCapture("Explicit host exit preempted held capture", resumeCanvas = false)
1394
+ canvas.exit()
1395
+ }
1339
1396
  }
1340
1397
 
1341
1398
  override fun sendShutdown() {
@@ -1545,23 +1602,16 @@ class Nimo : SGCManager() {
1545
1602
  stopScan()
1546
1603
  lastDeviceName = name
1547
1604
  lastDeviceAddress = device.address
1548
- connectGattLe(device)
1605
+ connectCompanionGatt(device)
1549
1606
  }
1550
1607
 
1551
1608
  /**
1552
- * Vendor connection model (from bitfantasy_glasses_sdk_transport_ble + their
1553
- * flutter_blue_plus fork notes, "Android CTKD transport=BREDR"):
1554
- *
1555
- * 1. The main device is discovered over CLASSIC Bluetooth (it never advertises its
1556
- * name over LE; the "<name>_BLE" peripheral is a separate ANCS-only device).
1557
- * 2. Pairing is a CLASSIC (BR/EDR) bond. CTKD (cross-transport key derivation)
1558
- * derives the LE keys — critically the IRK — during that bond.
1559
- * 3. The DATA channel is GATT over LE (service 7033). The glasses advertise with a
1560
- * resolvable private address, so connecting by their public MAC only works once
1561
- * the phone holds the IRK from step 2. (Same reason iOS requires Settings-app
1562
- * pairing first — iOS performs the classic bond + CTKD itself.)
1609
+ * Discover and bond the main device over classic Bluetooth, then use BR/EDR ATT
1610
+ * for Companion service 7033. The separate <name>_BLE ANCS peripheral is not the
1611
+ * canvas data channel. This matches the vendor transport=BREDR connection path.
1563
1612
  */
1564
- private fun connectGattLe(device: android.bluetooth.BluetoothDevice) {
1613
+ private fun connectCompanionGatt(device: android.bluetooth.BluetoothDevice) {
1614
+ if (isDisconnecting || gatt != null) return
1565
1615
  // Classic discovery degrades/aborts connections — always cancel first.
1566
1616
  try {
1567
1617
  bluetoothAdapter?.cancelDiscovery()
@@ -1574,11 +1624,11 @@ class Nimo : SGCManager() {
1574
1624
  android.bluetooth.BluetoothDevice.BOND_NONE
1575
1625
  }
1576
1626
  if (bondState != android.bluetooth.BluetoothDevice.BOND_BONDED) {
1577
- // Bond over classic first (CTKD gives us the LE keys); connect on completion.
1627
+ // Bond over classic first, then open the Companion bearer on completion.
1578
1628
  startBond(device)
1579
1629
  return
1580
1630
  }
1581
- connectLeTransport(device)
1631
+ connectBredrTransport(device)
1582
1632
  }
1583
1633
 
1584
1634
  /**
@@ -1597,7 +1647,7 @@ class Nimo : SGCManager() {
1597
1647
  if (bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
1598
1648
  Bridge.log("NIMO: already bonded with ${device.address} — connecting")
1599
1649
  unregisterBondReceiver()
1600
- connectLeTransport(device)
1650
+ connectBredrTransport(device)
1601
1651
  return
1602
1652
  }
1603
1653
  // Don't fire a second createBond on top of one already in flight (duplicate
@@ -1625,13 +1675,14 @@ class Nimo : SGCManager() {
1625
1675
  false
1626
1676
  }
1627
1677
  if (!started) {
1628
- Bridge.log("NIMO: createBond failed to start — trying LE connect anyway")
1678
+ Bridge.log("NIMO: createBond failed to start — trying Companion BR/EDR connection")
1629
1679
  unregisterBondReceiver()
1630
- connectLeTransport(device)
1680
+ connectBredrTransport(device)
1631
1681
  }
1632
1682
  }
1633
1683
 
1634
- private fun connectLeTransport(device: android.bluetooth.BluetoothDevice) {
1684
+ private fun connectBredrTransport(device: android.bluetooth.BluetoothDevice) {
1685
+ if (isDisconnecting || gatt != null) return
1635
1686
  // GATT over BR/EDR: the glasses pair with a legacy (non-SC) link key, so no LE
1636
1687
  // keys exist (le_linkkey_known:F) and the controller records the device as
1637
1688
  // BR_EDR-only — the 7033 UART service is served over the classic link. This is
@@ -1647,6 +1698,7 @@ class Nimo : SGCManager() {
1647
1698
  gattCallback,
1648
1699
  android.bluetooth.BluetoothDevice.TRANSPORT_BREDR
1649
1700
  )
1701
+ gatt?.let { writes.connected(it) }
1650
1702
  }
1651
1703
 
1652
1704
  private var bondReceiver: android.content.BroadcastReceiver? = null
@@ -1688,7 +1740,7 @@ class Nimo : SGCManager() {
1688
1740
  // (right after pairing) succeeds instead of failing until the
1689
1741
  // user manually retries into the already-bonded fast path.
1690
1742
  mainHandler.postDelayed(
1691
- { connectLeTransport(device) },
1743
+ { connectBredrTransport(device) },
1692
1744
  POST_BOND_SETTLE_MS
1693
1745
  )
1694
1746
  }
@@ -1743,7 +1795,7 @@ class Nimo : SGCManager() {
1743
1795
  return try {
1744
1796
  val device = adapter.getRemoteDevice(address)
1745
1797
  Bridge.log("NIMO: connectByAddress - ${device.name ?: address}")
1746
- connectGattLe(device)
1798
+ connectCompanionGatt(device)
1747
1799
  true
1748
1800
  } catch (e: Exception) {
1749
1801
  Bridge.log("NIMO: connectByAddress failed: ${e.message}")
@@ -1774,18 +1826,19 @@ class Nimo : SGCManager() {
1774
1826
  object : BluetoothGattCallback() {
1775
1827
  override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
1776
1828
  mainHandler.post {
1829
+ if (g !== gatt) return@post
1777
1830
  Bridge.log("NIMO: onConnectionStateChange status=$status newState=$newState")
1778
- if (newState == BluetoothProfile.STATE_CONNECTED) {
1831
+ if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) {
1779
1832
  Bridge.log("NIMO: Connected to ${g.device?.name ?: "unknown"}")
1780
1833
  lastDeviceAddress = g.device?.address
1781
1834
  // Do NOT request a large MTU here: the vendor transport warns that
1782
1835
  // requesting one during connect makes some Nimo firmwares throw a
1783
1836
  // GATT error and loop reconnecting. The system negotiates the MTU;
1784
- // frames larger than it go out as ATT long writes (WRITE_TYPE_DEFAULT).
1837
+ // canvas fragments fit the currently known writable ATT size.
1785
1838
  try {
1786
- g.discoverServices()
1839
+ if (!g.discoverServices()) abortTransport("Service discovery did not start")
1787
1840
  } catch (e: SecurityException) {
1788
- Bridge.log("NIMO: discoverServices SecurityException: ${e.message}")
1841
+ abortTransport("Service discovery permission denied")
1789
1842
  }
1790
1843
  } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
1791
1844
  Bridge.log("NIMO: Disconnected")
@@ -1802,6 +1855,8 @@ class Nimo : SGCManager() {
1802
1855
  DeviceStore.apply("glasses", "fullyBooted", false)
1803
1856
  DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1804
1857
  startReconnectionTimer()
1858
+ } else if (status != BluetoothGatt.GATT_SUCCESS) {
1859
+ abortTransport("GATT connection failed status=$status")
1805
1860
  }
1806
1861
  }
1807
1862
  }
@@ -1809,21 +1864,19 @@ class Nimo : SGCManager() {
1809
1864
  override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
1810
1865
  Bridge.log("NIMO: onMtuChanged mtu=$mtu status=$status")
1811
1866
  mainHandler.post {
1812
- negotiatedMtu = mtu
1813
- try {
1814
- g.discoverServices()
1815
- } catch (e: SecurityException) {
1816
- Bridge.log("NIMO: discoverServices SecurityException: ${e.message}")
1817
- }
1867
+ if (g !== gatt || status != BluetoothGatt.GATT_SUCCESS) return@post
1868
+ negotiatedMtu = mtu.coerceIn(23, 517)
1818
1869
  }
1819
1870
  }
1820
1871
 
1821
1872
  override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
1822
1873
  if (status != BluetoothGatt.GATT_SUCCESS) {
1823
1874
  Bridge.log("NIMO: onServicesDiscovered failed status=$status")
1875
+ mainHandler.post { if (g === gatt) abortTransport("Service discovery failed status=$status") }
1824
1876
  return
1825
1877
  }
1826
1878
  mainHandler.post {
1879
+ if (g !== gatt) return@post
1827
1880
  val service = g.getService(NimoBLE.SERVICE_UUID)
1828
1881
  if (service == null) {
1829
1882
  // Dump everything so a UUID mismatch is diagnosable from logs.
@@ -1850,8 +1903,9 @@ class Nimo : SGCManager() {
1850
1903
  return@post
1851
1904
  }
1852
1905
  enableNotifications(g, rxChar!!) {
1853
- micChar?.let { mic -> enableNotifications(g, mic) {} }
1854
- startHandshake()
1906
+ val mic = micChar
1907
+ if (mic == null) startHandshake()
1908
+ else enableNotifications(g, mic) { startHandshake() }
1855
1909
  }
1856
1910
  }
1857
1911
  }
@@ -1861,10 +1915,15 @@ class Nimo : SGCManager() {
1861
1915
  g: BluetoothGatt,
1862
1916
  characteristic: BluetoothGattCharacteristic
1863
1917
  ) {
1864
- val data = characteristic.value ?: return
1865
- when (characteristic.uuid) {
1866
- NimoBLE.CHAR_RX -> mainHandler.post { handleRxPacket(data) }
1867
- NimoBLE.CHAR_MIC -> audioClient.enqueue(data)
1918
+ val data = characteristic.value?.copyOf() ?: return
1919
+ mainHandler.post {
1920
+ if (g !== gatt) return@post
1921
+ when (characteristic.uuid) {
1922
+ NimoBLE.CHAR_RX -> if (characteristic.service?.uuid == NimoBLE.SERVICE_UUID) {
1923
+ handleRxPacket(data)
1924
+ }
1925
+ NimoBLE.CHAR_MIC -> audioClient.enqueue(data)
1926
+ }
1868
1927
  }
1869
1928
  }
1870
1929
 
@@ -1873,13 +1932,9 @@ class Nimo : SGCManager() {
1873
1932
  characteristic: BluetoothGattCharacteristic,
1874
1933
  status: Int
1875
1934
  ) {
1876
- mainHandler.postDelayed(
1877
- {
1878
- writeInFlight = false
1879
- drainWriteQueue()
1880
- },
1881
- NimoBLE.INTER_FRAME_DELAY_MS
1882
- )
1935
+ mainHandler.post {
1936
+ writes.written(g, characteristic, status == BluetoothGatt.GATT_SUCCESS)
1937
+ }
1883
1938
  }
1884
1939
 
1885
1940
  override fun onDescriptorWrite(
@@ -1887,78 +1942,38 @@ class Nimo : SGCManager() {
1887
1942
  descriptor: BluetoothGattDescriptor,
1888
1943
  status: Int
1889
1944
  ) {
1890
- mainHandler.post { descriptorWriteCompletion?.let { it() } }
1945
+ mainHandler.post {
1946
+ writes.descriptorWritten(g, descriptor, status == BluetoothGatt.GATT_SUCCESS)
1947
+ }
1891
1948
  }
1892
1949
  }
1893
1950
 
1894
- private var descriptorWriteCompletion: (() -> Unit)? = null
1895
-
1896
1951
  @Suppress("deprecation")
1897
1952
  private fun enableNotifications(
1898
1953
  g: BluetoothGatt,
1899
1954
  characteristic: BluetoothGattCharacteristic,
1900
1955
  onComplete: () -> Unit
1901
1956
  ) {
1902
- g.setCharacteristicNotification(characteristic, true)
1957
+ if (g !== gatt || !g.setCharacteristicNotification(characteristic, true)) {
1958
+ abortTransport("Could not enable local notifications")
1959
+ return
1960
+ }
1903
1961
  val descriptor = characteristic.getDescriptor(NimoBLE.CLIENT_CHARACTERISTIC_CONFIG)
1904
1962
  if (descriptor != null) {
1905
- descriptorWriteCompletion = {
1906
- descriptorWriteCompletion = null
1907
- onComplete()
1908
- }
1909
1963
  descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
1910
- g.writeDescriptor(descriptor)
1964
+ writes.subscribe(g, descriptor, { g.writeDescriptor(descriptor) }, onComplete)
1911
1965
  } else {
1912
- onComplete()
1966
+ abortTransport("Notification descriptor is missing")
1913
1967
  }
1914
1968
  }
1915
1969
 
1916
1970
  // ---------- Write Queue ----------
1917
1971
 
1918
- @Suppress("deprecation")
1919
- private fun drainWriteQueue() {
1920
- if (writeInFlight) return
1921
- val item = writeQueue.removeFirstOrNull() ?: return
1922
- val g = gatt
1923
- if (g == null) {
1924
- writeQueue.clear()
1925
- return
1926
- }
1927
- writeInFlight = true
1928
- item.char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
1929
- item.char.value = item.bytes
1930
- val ok =
1931
- try {
1932
- g.writeCharacteristic(item.char)
1933
- } catch (e: Exception) {
1934
- Bridge.log("NIMO: writeCharacteristic failed: ${e.message}")
1935
- false
1936
- }
1937
- if (!ok) {
1938
- // Another GATT op (e.g. a descriptor write) is in flight — requeue and retry
1939
- // shortly instead of dropping the frame.
1940
- writeInFlight = false
1941
- writeQueue.addFirst(item)
1942
- mainHandler.postDelayed({ drainWriteQueue() }, 100)
1943
- return
1944
- }
1945
- // Watchdog: if the write callback never arrives, unblock the queue.
1946
- writeWatchdog?.let { mainHandler.removeCallbacks(it) }
1947
- val watchdog = Runnable {
1948
- if (writeInFlight) {
1949
- Bridge.log("NIMO: write watchdog fired — forcing queue drain")
1950
- writeInFlight = false
1951
- drainWriteQueue()
1952
- }
1953
- }
1954
- writeWatchdog = watchdog
1955
- mainHandler.postDelayed(watchdog, WRITE_WATCHDOG_MS)
1956
- }
1957
-
1958
1972
  private fun enqueueWrite(char: BluetoothGattCharacteristic, bytes: ByteArray) {
1973
+ val expectedGatt = gatt ?: return
1959
1974
  mainHandler.post {
1960
- writeQueue.addLast(QueuedWrite(char, bytes))
1961
- drainWriteQueue()
1975
+ if (gatt !== expectedGatt) return@post
1976
+ writes.enqueue(expectedGatt, char, listOf(bytes))
1962
1977
  }
1963
1978
  }
1964
1979
 
@@ -1967,14 +1982,25 @@ class Nimo : SGCManager() {
1967
1982
  enqueueWrite(tx, frame)
1968
1983
  }
1969
1984
 
1970
- private fun enqueueFrames(frames: List<ByteArray>) {
1971
- val tx = txChar ?: return
1972
- mainHandler.post {
1973
- for (frame in frames) {
1974
- writeQueue.addLast(QueuedWrite(tx, frame))
1975
- }
1976
- drainWriteQueue()
1977
- }
1985
+ private fun enqueueFrames(frames: List<ByteArray>, finalStarted: () -> Unit,
1986
+ completed: () -> Unit): Boolean {
1987
+ val tx = txChar ?: return false
1988
+ val connection = gatt ?: return false
1989
+ // Coordinator and GATT callbacks are Main-confined: enqueue the complete chain atomically.
1990
+ return writes.enqueue(connection, tx, frames, finalStarted, completed)
1991
+ }
1992
+
1993
+ private fun abortTransport(reason: String) {
1994
+ Bridge.log("NIMO: resetting transport: $reason")
1995
+ val old = gatt
1996
+ gatt = null
1997
+ txChar = null; rxChar = null; micChar = null
1998
+ resetSessionState()
1999
+ try { old?.disconnect(); old?.close() } catch (_: Exception) {}
2000
+ DeviceStore.apply("glasses", "connected", false)
2001
+ DeviceStore.apply("glasses", "fullyBooted", false)
2002
+ DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
2003
+ if (!isDisconnecting) startReconnectionTimer()
1978
2004
  }
1979
2005
 
1980
2006
  // ---------- Pending ACKs ----------
@@ -1993,7 +2019,11 @@ class Nimo : SGCManager() {
1993
2019
  it.onResult(false)
1994
2020
  }
1995
2021
  val timeout = Runnable {
1996
- pendingAcks.remove(ackKey)?.onResult(false)
2022
+ // Do not retry a timed-out key on the same transport: a late response is uncorrelated.
2023
+ pendingAcks.remove(ackKey)?.let { expired ->
2024
+ abortTransport("ACK timeout cmd=$cmd key=$key")
2025
+ expired.onResult(false)
2026
+ }
1997
2027
  }
1998
2028
  pendingAcks[ackKey] = PendingAck(onResult, timeout)
1999
2029
  mainHandler.postDelayed(timeout, timeoutMs)
@@ -2057,12 +2087,14 @@ class Nimo : SGCManager() {
2057
2087
 
2058
2088
  private fun attemptSetTime() {
2059
2089
  if (handshakeState != HandshakeState.AWAITING_TIME_ACK || gatt == null) return
2090
+ val expectedGatt = gatt
2060
2091
  setTimeAttempts++
2061
2092
  sendAwaitingAck(
2062
2093
  NimoProtocol.CMD_SET_PARAMETER,
2063
2094
  NimoProtocol.SET_TIME,
2064
2095
  NimoFrameCodec.encodeDeviceTime()
2065
2096
  ) { ok ->
2097
+ if (gatt !== expectedGatt || handshakeState != HandshakeState.AWAITING_TIME_ACK) return@sendAwaitingAck
2066
2098
  if (ok) {
2067
2099
  finishHandshake()
2068
2100
  } else if (setTimeAttempts < SET_TIME_MAX_ATTEMPTS &&
@@ -2072,7 +2104,7 @@ class Nimo : SGCManager() {
2072
2104
  // The firmware can answer "busy" right after the link comes up —
2073
2105
  // give it a moment and retry instead of dropping the connection.
2074
2106
  Bridge.log("NIMO: setTime attempt $setTimeAttempts failed — retrying")
2075
- mainHandler.postDelayed({ attemptSetTime() }, SET_TIME_RETRY_DELAY_MS)
2107
+ mainHandler.postDelayed({ if (gatt === expectedGatt) attemptSetTime() }, SET_TIME_RETRY_DELAY_MS)
2076
2108
  } else {
2077
2109
  Bridge.log("NIMO: setTime failed after $setTimeAttempts attempts — handshake failed")
2078
2110
  handshakeFailed()
@@ -2091,6 +2123,9 @@ class Nimo : SGCManager() {
2091
2123
  needsAck = false
2092
2124
  )
2093
2125
  )
2126
+ // Optional setting: older firmware may reject it without preventing connection.
2127
+ sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_SET_PARAMETER,
2128
+ NimoProtocol.SET_SYSTEM_LANGUAGE, byteArrayOf(NimoProtocol.LANGUAGE_ENGLISH.toByte())))
2094
2129
  getBatteryStatus()
2095
2130
  requestVersionInfo()
2096
2131
 
@@ -2100,6 +2135,20 @@ class Nimo : SGCManager() {
2100
2135
  DeviceStore.apply("glasses", "fullyBooted", true)
2101
2136
  DeviceStore.apply("glasses", "connectionState", ConnTypes.CONNECTED)
2102
2137
  startTimers()
2138
+ if (diagnostics == null) {
2139
+ diagnostics = NimoDiagnostics(
2140
+ context,
2141
+ mainHandler,
2142
+ { frame, completed -> enqueueFrames(listOf(frame), {}, completed) },
2143
+ { ready -> canvas.hold(ready) },
2144
+ { resume -> canvas.releaseHold(resume) },
2145
+ )
2146
+ }
2147
+ diagnostics?.connected((negotiatedMtu - 3).coerceIn(20, 512))
2148
+ // Launch checks peer readiness in firmware; do not wait up to a minute for
2149
+ // the first unsolicited heartbeat just because peer status is still unknown.
2150
+ canvas.activate()
2151
+ canvas.readiness(twsConnected && peerCompanionReady != false)
2103
2152
  }
2104
2153
 
2105
2154
  private fun handshakeFailed() {
@@ -2109,18 +2158,21 @@ class Nimo : SGCManager() {
2109
2158
  }
2110
2159
 
2111
2160
  private fun resetSessionState() {
2161
+ diagnostics?.disconnected()
2112
2162
  handshakeState = HandshakeState.IDLE
2163
+ cancelTwsTimeout()
2113
2164
  twsConnected = false
2114
- textAppEntered = false
2115
- navAppEntered = false
2116
- currentGlassesAppId = -1
2117
- pendingText = null
2165
+ peerCompanionReady = null
2166
+ canvasEncoder.invalidate()
2167
+ canvas.disconnected()
2118
2168
  audioClient.stop()
2169
+ // Nimo's decoder/stream belongs to this GATT session. Consumer demand
2170
+ // remains in bluetooth.should_send_*; readiness must start a fresh stream
2171
+ // even when reconnecting with this same adapter instance.
2172
+ DeviceStore.apply("glasses", "micEnabled", false)
2119
2173
  receiveAssembler.reset()
2120
- writeQueue.clear()
2121
- writeInFlight = false
2122
- writeWatchdog?.let { mainHandler.removeCallbacks(it) }
2123
- writeWatchdog = null
2174
+ writes.reset()
2175
+ negotiatedMtu = 23
2124
2176
  failAllPendingAcks()
2125
2177
  stopTimers()
2126
2178
  }
@@ -2142,7 +2194,7 @@ class Nimo : SGCManager() {
2142
2194
  }
2143
2195
  }
2144
2196
 
2145
- // ---------- Timers (battery poll keepalive + text queue) ----------
2197
+ // ---------- Timers (battery poll keepalive) ----------
2146
2198
 
2147
2199
  private fun startTimers() {
2148
2200
  stopTimers()
@@ -2158,123 +2210,31 @@ class Nimo : SGCManager() {
2158
2210
  batteryPollRunnable = battery
2159
2211
  mainHandler.postDelayed(battery, BATTERY_POLL_MS)
2160
2212
 
2161
- val textQueue =
2162
- object : Runnable {
2163
- override fun run() {
2164
- drainTextQueue()
2165
- mainHandler.postDelayed(this, TEXT_QUEUE_TICK_MS)
2166
- }
2167
- }
2168
- textQueueRunnable = textQueue
2169
- mainHandler.postDelayed(textQueue, TEXT_QUEUE_TICK_MS)
2170
2213
  }
2171
2214
 
2172
2215
  private fun stopTimers() {
2173
2216
  batteryPollRunnable?.let { mainHandler.removeCallbacks(it) }
2174
2217
  batteryPollRunnable = null
2175
- textQueueRunnable?.let { mainHandler.removeCallbacks(it) }
2176
- textQueueRunnable = null
2177
- }
2178
-
2179
- // ---------- Text Rendering ----------
2180
-
2181
- /**
2182
- * TEMP HW TEST: build a 160x160 grayscale test pattern (four vertical bands
2183
- * at the 4 quantization levels: black | dark | light | white) and push it
2184
- * through the exact same nav-minimap pipeline as displayBitmap. If the bands
2185
- * render left→right on the glasses, the SDK bitmap path works end-to-end.
2186
- */
2187
- private fun displaySampleBitmap() {
2188
- val w = 160
2189
- val h = 160
2190
- val gray = ByteArray(w * h)
2191
- for (y in 0 until h) {
2192
- for (x in 0 until w) {
2193
- val band = (x * 4 / w).coerceIn(0, 3) // 0..3 across the width
2194
- gray[y * w + x] = (band * 85).toByte() // 0, 85, 170, 255
2195
- }
2196
- }
2197
- val packed = packL8To2bpp(gray)
2198
- val (payload, compression) = compressAdaptive(packed)
2199
- if (!navAppEntered) {
2200
- sendFrame(
2201
- NimoFrameCodec.encodeFrame(
2202
- NimoProtocol.CMD_CONTROL_INSTRUCTION,
2203
- NimoProtocol.CTRL_ENTER_APP,
2204
- byteArrayOf(
2205
- NimoProtocol.APP_ID_NAV.toByte(),
2206
- NimoProtocol.APP_MODE_STANDALONE.toByte()
2207
- )
2208
- )
2209
- )
2210
- navAppEntered = true
2211
- }
2212
- val content =
2213
- NimoFrameCodec.imageHeader(
2214
- width = w,
2215
- height = h,
2216
- formatBpp = NimoProtocol.FORMAT_2BPP,
2217
- compression = compression,
2218
- originalSize = packed.size,
2219
- compressedSize =
2220
- if (compression == NimoProtocol.COMPRESSION_NONE) 0
2221
- else payload.size
2222
- ) + payload
2223
- val frames =
2224
- NimoFrameCodec.updateContentFrames(
2225
- appId = NimoProtocol.APP_ID_NAV,
2226
- layoutId = 0,
2227
- resId = 0,
2228
- resType = NimoProtocol.WIDGET_PICTURE,
2229
- content = content
2230
- )
2231
- enqueueFrames(frames)
2232
- Bridge.log(
2233
- "NIMO: displaySampleBitmap → ${frames.size} frames, ${payload.size}B comp=$compression"
2234
- )
2235
- }
2236
-
2237
- private fun drainTextQueue() {
2238
- val text = pendingText ?: return
2239
- if (handshakeState != HandshakeState.READY) return
2240
- pendingText = null
2241
-
2242
- // All text (text_wall / double_text_wall / reference_card / positioned_text) renders on
2243
- // the ASR note page — the only text surface confirmed to render on hardware.
2244
- Bridge.log("NIMO: text → glasses (enterApp=${!textAppEntered}): \"${text.take(60)}\"")
2245
- if (!textAppEntered) {
2246
- sendFrame(
2247
- NimoFrameCodec.encodeFrame(
2248
- NimoProtocol.CMD_CONTROL_INSTRUCTION,
2249
- NimoProtocol.CTRL_ENTER_APP,
2250
- byteArrayOf(textAppId.toByte(), NimoProtocol.APP_MODE_STANDALONE.toByte())
2251
- )
2252
- )
2253
- // Optimistic; corrected by app-state reports if the glasses refuse/exit.
2254
- textAppEntered = true
2255
- }
2256
-
2257
- val frames =
2258
- NimoFrameCodec.updateContentFrames(
2259
- appId = textAppId,
2260
- layoutId = textLayoutId,
2261
- resId = textResId,
2262
- resType = NimoProtocol.WIDGET_TEXT_NEW,
2263
- content = text.toByteArray(Charsets.UTF_8)
2264
- )
2265
- enqueueFrames(frames)
2266
2218
  }
2267
2219
 
2268
2220
  // ---------- Incoming Data ----------
2269
2221
 
2270
2222
  private fun handleRxPacket(packet: ByteArray) {
2223
+ // Diagnostic packets must retain their original wire CRC and framing.
2224
+ if (diagnostics?.onPacket(packet) == true) return
2225
+ if (packet.size >= 10 && packet[8].toInt() == 7 && (packet[9].toInt() and 255) in listOf(1, 3, 4)) {
2226
+ val response = NimoCanvasCodec.response(packet) ?: return
2227
+ if (response.first == 1) Bridge.log("NIMO: canvas Launch response status=${response.second.firstOrNull()?.toInt()}")
2228
+ canvas.response(response.first, response.second)
2229
+ return
2230
+ }
2271
2231
  receiveAssembler.cleanup()
2272
2232
  for (frame in receiveAssembler.ingest(packet)) {
2273
2233
  val decoded = NimoFrameCodec.decode(frame) ?: continue
2274
2234
  val cmd = decoded.cmd ?: continue
2275
2235
  val key = decoded.key ?: continue
2276
2236
  if (cmd == NimoProtocol.CMD_INSTRUCTION_REPORT) {
2277
- handleReport(key, decoded.data ?: ByteArray(0))
2237
+ if (decoded.statusCode == 0) handleReport(key, decoded.data ?: ByteArray(0))
2278
2238
  } else {
2279
2239
  handleResponse(cmd, key, decoded.statusCode ?: 1, decoded.data ?: ByteArray(0))
2280
2240
  }
@@ -2293,14 +2253,16 @@ class Nimo : SGCManager() {
2293
2253
  Bridge.log("NIMO: app state report appId=$appId phase=$phase")
2294
2254
  when (phase) {
2295
2255
  NimoProtocol.STATE_ENTER -> {
2296
- currentGlassesAppId = appId
2297
- if (appId != textAppId) textAppEntered = false
2298
- if (appId != NimoProtocol.APP_ID_NAV) navAppEntered = false
2256
+ if (appId != NimoCanvasCodec.APP_ID) {
2257
+ diagnostics?.cancelHeldCapture("Native app takeover preempted held capture", resumeCanvas = false)
2258
+ }
2259
+ canvas.nativeApp(appId, true)
2299
2260
  }
2300
2261
  NimoProtocol.STATE_EXIT -> {
2301
- if (appId == currentGlassesAppId) currentGlassesAppId = -1
2302
- if (appId == textAppId) textAppEntered = false
2303
- if (appId == NimoProtocol.APP_ID_NAV) navAppEntered = false
2262
+ if (appId == NimoCanvasCodec.APP_ID) {
2263
+ diagnostics?.cancelHeldCapture("Canvas exit preempted held capture", resumeCanvas = false)
2264
+ }
2265
+ canvas.nativeApp(appId, false)
2304
2266
  }
2305
2267
  }
2306
2268
  }
@@ -2311,7 +2273,10 @@ class Nimo : SGCManager() {
2311
2273
  }
2312
2274
  }
2313
2275
  NimoProtocol.REPORT_BUSINESS -> handleBusinessReport(data)
2314
- NimoProtocol.REPORT_GATT_STATE -> {}
2276
+ NimoProtocol.REPORT_GATT_STATE -> {
2277
+ // 0605's payload is undocumented. Only the heartbeat's defined
2278
+ // slaveGatt field can establish peer Companion readiness.
2279
+ }
2315
2280
  else -> Bridge.log("NIMO: unknown report key=$key")
2316
2281
  }
2317
2282
  }
@@ -2324,7 +2289,13 @@ class Nimo : SGCManager() {
2324
2289
  NimoProtocol.BUSINESS_HEARTBEAT -> {
2325
2290
  // [leftMv(2)][rightMv(2)][btSysStatus(4)][twsStatus(1)][slaveGatt(1)]
2326
2291
  if (v.size >= 10) {
2292
+ peerCompanionReady = v[9].toInt() != 0
2327
2293
  onTwsState((v[8].toInt() and 0xFF) >= 1)
2294
+ // Both predicates were sampled by this device report. TWS-only
2295
+ // reports and cached state cannot release a status-7 wait.
2296
+ if (handshakeState == HandshakeState.READY) {
2297
+ canvas.confirmedReadiness(twsConnected && peerCompanionReady != false)
2298
+ }
2328
2299
  }
2329
2300
  }
2330
2301
  NimoProtocol.BUSINESS_BATTERY -> {
@@ -2349,6 +2320,7 @@ class Nimo : SGCManager() {
2349
2320
  if (!connected && handshakeState == HandshakeState.READY) {
2350
2321
  Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
2351
2322
  }
2323
+ if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady != false)
2352
2324
  }
2353
2325
 
2354
2326
  private fun handleInputEvent(code: Int) {
@@ -2384,6 +2356,9 @@ class Nimo : SGCManager() {
2384
2356
  // 5 not supported, 6 bad parameter, 7 device busy.
2385
2357
  Bridge.log("NIMO: response cmd=$cmd key=$key error status=$statusCode")
2386
2358
  }
2359
+ if (cmd == NimoProtocol.CMD_SET_PARAMETER && key == NimoProtocol.SET_SYSTEM_LANGUAGE) {
2360
+ Bridge.log("NIMO: English menu setting acknowledged=${statusCode == 0} status=$statusCode")
2361
+ }
2387
2362
  resolvePendingAck(cmd, key, statusCode == 0)
2388
2363
 
2389
2364
  if (cmd != NimoProtocol.CMD_GET_PARAMETER || statusCode != 0) return
@@ -2448,20 +2423,15 @@ class Nimo : SGCManager() {
2448
2423
 
2449
2424
  // ---------- Bitmap Helpers ----------
2450
2425
 
2451
- /** Aspect-fit the bitmap into [width]x[height] on black, then convert to L8 grayscale. */
2426
+ /** Scale into the exact layout box, compositing alpha over black, then convert to L8. */
2452
2427
  private fun bitmapToGrayscale(source: Bitmap, width: Int, height: Int): ByteArray {
2453
2428
  val scaled = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
2454
2429
  val canvas = Canvas(scaled)
2455
2430
  canvas.drawColor(Color.BLACK)
2456
- val scale = minOf(width.toFloat() / source.width, height.toFloat() / source.height)
2457
- val dw = (source.width * scale).toInt()
2458
- val dh = (source.height * scale).toInt()
2459
- val left = (width - dw) / 2
2460
- val top = (height - dh) / 2
2461
2431
  canvas.drawBitmap(
2462
2432
  source,
2463
2433
  null,
2464
- Rect(left, top, left + dw, top + dh),
2434
+ Rect(0, 0, width, height),
2465
2435
  Paint(Paint.FILTER_BITMAP_FLAG)
2466
2436
  )
2467
2437
 
@@ -2471,62 +2441,11 @@ class Nimo : SGCManager() {
2471
2441
  for (i in pixels.indices) {
2472
2442
  val p = pixels[i]
2473
2443
  val luminance =
2474
- (0.299 * Color.red(p) + 0.587 * Color.green(p) + 0.114 * Color.blue(p)).toInt()
2475
- // Invert luminance. MentraOS app bitmaps follow the platform convention of dark
2476
- // content on a light background (see BitmapJavaUtils: gray<128 -> "black"). Nimo's
2477
- // additive display lights up high 2bpp values (3 = white), so sending luma as-is
2478
- // lights the whole lens for the background and leaves the content dark — the
2479
- // inverted look. 255 - luma flips it so content renders lit on a dark lens.
2480
- gray[i] = (255 - luminance.coerceIn(0, 255)).toByte()
2444
+ (77 * Color.red(p) + 150 * Color.green(p) + 29 * Color.blue(p) + 128) shr 8
2445
+ gray[i] = luminance.coerceIn(0, 255).toByte()
2481
2446
  }
2482
2447
  scaled.recycle()
2483
2448
  return gray
2484
2449
  }
2485
2450
 
2486
- /** L8 → 2bpp: thresholds 0x40/0x80/0xC0, 4 pixels per byte, MSB first. */
2487
- private fun packL8To2bpp(l8: ByteArray): ByteArray {
2488
- val outBytes = (l8.size + 3) shr 2
2489
- val dst = ByteArray(outBytes)
2490
- for (i in 0 until outBytes) {
2491
- var packed = 0
2492
- for (j in 0 until 4) {
2493
- val idx = i * 4 + j
2494
- var value = 0
2495
- if (idx < l8.size) {
2496
- val px = l8[idx].toInt() and 0xFF
2497
- value =
2498
- when {
2499
- px < 0x40 -> 0
2500
- px < 0x80 -> 1
2501
- px < 0xC0 -> 2
2502
- else -> 3
2503
- }
2504
- }
2505
- packed = packed or ((value and 0x03) shl (6 - 2 * j))
2506
- }
2507
- dst[i] = packed.toByte()
2508
- }
2509
- return dst
2510
- }
2511
-
2512
- /** zlib-compress; falls back to uncompressed when that isn't smaller. */
2513
- private fun compressAdaptive(data: ByteArray): Pair<ByteArray, Int> {
2514
- if (data.isEmpty()) return Pair(data, NimoProtocol.COMPRESSION_NONE)
2515
- val deflater = Deflater()
2516
- deflater.setInput(data)
2517
- deflater.finish()
2518
- val out = ByteArrayOutputStream(data.size)
2519
- val buffer = ByteArray(4096)
2520
- while (!deflater.finished()) {
2521
- val n = deflater.deflate(buffer)
2522
- out.write(buffer, 0, n)
2523
- }
2524
- deflater.end()
2525
- val compressed = out.toByteArray()
2526
- return if (compressed.size < data.size) {
2527
- Pair(compressed, NimoProtocol.COMPRESSION_ZLIB)
2528
- } else {
2529
- Pair(data, NimoProtocol.COMPRESSION_NONE)
2530
- }
2531
- }
2532
2451
  }