@mentra/bluetooth-sdk 3.3.0-dev.317 → 3.3.0-dev.321

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 (46) hide show
  1. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +37 -12
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -13
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3 -2
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  14. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  21. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  22. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  23. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  24. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  25. package/build/generated/releaseMetadata.js +5 -5
  26. package/build/generated/releaseMetadata.js.map +1 -1
  27. package/ios/MentraBluetoothSDK.podspec +3 -2
  28. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  29. package/ios/Source/DeviceManager.swift +23 -10
  30. package/ios/Source/DeviceStore.swift +19 -7
  31. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  32. package/ios/Source/sgcs/G2.swift +2 -2
  33. package/ios/Source/sgcs/Nimo.swift +182 -371
  34. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  35. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  36. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  37. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  38. package/ios/Source/sgcs/SGCManager.swift +16 -0
  39. package/ios/Source/utils/G2Text.swift +11 -0
  40. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  41. package/ios/Tests/G2TextTests.swift +10 -0
  42. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  43. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  44. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  45. package/package.json +2 -2
  46. package/src/generated/releaseMetadata.ts +5 -5
@@ -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
 
@@ -877,6 +878,11 @@ private class NimoReconnectionManager(
877
878
  // ---------- Nimo Class ----------
878
879
 
879
880
  class Nimo : SGCManager() {
881
+ override val sceneHandoffRequiresClear = false
882
+ // A legacy text confirmation replaces the active canvas; brightness itself does not.
883
+ override val showBrightnessConfirmation = false
884
+ // The host replays its scene on connection; a later welcome clear would erase it.
885
+ override val showConnectionConfirmation = false
880
886
 
881
887
  companion object {
882
888
  private const val PREFS_NAME = "NimoPrefs"
@@ -898,8 +904,6 @@ class Nimo : SGCManager() {
898
904
  private const val MAX_BOND_ATTEMPTS = 3
899
905
  private const val BOND_RETRY_DELAY_MS = 2_000L
900
906
  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
907
  private const val SET_TIME_MAX_ATTEMPTS = 3
904
908
  private const val SET_TIME_RETRY_DELAY_MS = 500L
905
909
  }
@@ -909,12 +913,25 @@ class Nimo : SGCManager() {
909
913
  hasMic = true
910
914
  }
911
915
 
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
916
+ private val mainHandler = Handler(Looper.getMainLooper())
917
+ private val mainScheduler = handlerScheduler(mainHandler)
918
+ // Variant-specific: release has no receiver or framebuffer protocol implementation.
919
+ private var diagnostics: NimoDiagnostics? = null
920
+ private val canvasEncoderThread = HandlerThread("NimoCanvasEncoder").apply { start() }
921
+ private val canvasEncoder = NimoCanvasEncoder(mainScheduler,
922
+ handlerScheduler(Handler(canvasEncoderThread.looper)),
923
+ { canvasEncoderThread.quitSafely() },
924
+ { Bridge.log("NIMO: canvas validation/encoding failed; retaining last valid scene: ${it.message}") })
925
+ private val canvas = NimoCanvasCoordinator(mainScheduler,
926
+ { (negotiatedMtu - 3).coerceIn(20, 512) }, ::enqueueFrames, ::abortTransport,
927
+ { Bridge.log("NIMO: canvas rejected status=$it; not a render ACK") },
928
+ { diagnostics?.cancelHeldCapture("Canvas scope takeover preempted held capture", resumeCanvas = false) })
929
+
930
+ private fun handlerScheduler(handler: Handler) = NimoScheduler { delay, task ->
931
+ val runnable = Runnable(task)
932
+ handler.postDelayed(runnable, delay)
933
+ ({ handler.removeCallbacks(runnable) })
934
+ }
918
935
 
919
936
  // BLE
920
937
  private val context: Context
@@ -926,7 +943,6 @@ class Nimo : SGCManager() {
926
943
  private var micChar: BluetoothGattCharacteristic? = null
927
944
  private var isDisconnecting = false
928
945
  private var negotiatedMtu = 23
929
-
930
946
  // Device search
931
947
  private var DEVICE_SEARCH_ID = "NOT_SET"
932
948
 
@@ -954,7 +970,6 @@ class Nimo : SGCManager() {
954
970
 
955
971
  private val reconnectionManager = NimoReconnectionManager()
956
972
  private val receiveAssembler = NimoReceiveAssembler()
957
- private val mainHandler = Handler(Looper.getMainLooper())
958
973
 
959
974
  // Handshake
960
975
  private enum class HandshakeState {
@@ -965,6 +980,7 @@ class Nimo : SGCManager() {
965
980
  }
966
981
  private var handshakeState = HandshakeState.IDLE
967
982
  private var twsConnected = false
983
+ private var peerCompanionReady: Boolean? = null
968
984
  private var twsTimeoutRunnable: Runnable? = null
969
985
  private var pairingTimeoutRunnable: Runnable? = null
970
986
 
@@ -972,20 +988,16 @@ class Nimo : SGCManager() {
972
988
  private class PendingAck(val onResult: (Boolean) -> Unit, val timeout: Runnable)
973
989
  private val pendingAcks = mutableMapOf<Int, PendingAck>()
974
990
 
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
991
+ // One queue owns characteristic writes and CCCD callbacks, including non-canvas commands.
992
+ private val writes = NimoGattQueue<BluetoothGattCharacteristic>(mainScheduler,
993
+ { characteristic, bytes ->
994
+ characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
995
+ characteristic.value = bytes
996
+ gatt?.writeCharacteristic(characteristic) ?: false
997
+ }, ::abortTransport, NimoBLE.INTER_FRAME_DELAY_MS)
981
998
 
982
- // Heartbeat / text queue
999
+ // Heartbeat
983
1000
  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
1001
 
990
1002
  // Battery
991
1003
  private var lastBatteryLevel = -1
@@ -1039,7 +1051,7 @@ class Nimo : SGCManager() {
1039
1051
  Bridge.log("NIMO: target is bonded — connecting directly (no discovery)")
1040
1052
  lastDeviceName = target.name
1041
1053
  lastDeviceAddress = target.address
1042
- mainHandler.post { connectGattLe(target) }
1054
+ mainHandler.post { connectCompanionGatt(target) }
1043
1055
  return true
1044
1056
  }
1045
1057
 
@@ -1056,6 +1068,12 @@ class Nimo : SGCManager() {
1056
1068
  }
1057
1069
 
1058
1070
  override fun disconnect() {
1071
+ if (Looper.myLooper() != mainHandler.looper) {
1072
+ // The manager can discard or replace this communicator immediately on return.
1073
+ // Finish local teardown on its owning looper before releasing that caller.
1074
+ runBlocking { withContext(Dispatchers.Main) { disconnect() } }
1075
+ return
1076
+ }
1059
1077
  Bridge.log("NIMO: disconnect()")
1060
1078
  isDisconnecting = true
1061
1079
  cancelPairingTimeout()
@@ -1080,6 +1098,10 @@ class Nimo : SGCManager() {
1080
1098
  }
1081
1099
 
1082
1100
  override fun forget() {
1101
+ if (Looper.myLooper() != mainHandler.looper) {
1102
+ runBlocking { withContext(Dispatchers.Main) { forget() } }
1103
+ return
1104
+ }
1083
1105
  Bridge.log("NIMO: forget()")
1084
1106
  disconnect()
1085
1107
  lastDeviceAddress = null
@@ -1088,7 +1110,14 @@ class Nimo : SGCManager() {
1088
1110
  }
1089
1111
 
1090
1112
  override fun cleanup() {
1113
+ if (Looper.myLooper() != mainHandler.looper) {
1114
+ runBlocking { withContext(Dispatchers.Main) { cleanup() } }
1115
+ return
1116
+ }
1117
+ canvasEncoder.close()
1091
1118
  disconnect()
1119
+ diagnostics?.close()
1120
+ diagnostics = null
1092
1121
  audioClient.stop()
1093
1122
  }
1094
1123
 
@@ -1150,20 +1179,15 @@ class Nimo : SGCManager() {
1150
1179
  }
1151
1180
 
1152
1181
  override fun clearDisplay() {
1153
- // Keep the text app/page alive — a blank update avoids re-entering the app next time.
1154
- sendTextWall(" ")
1182
+ submitCanvas("legacy") { emptyList() }
1155
1183
  }
1156
1184
 
1157
1185
  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
1186
+ sendTextWall(text)
1161
1187
  }
1162
1188
 
1163
1189
  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
1190
+ submitCanvas("legacy") { canvasText(text, 0, 0, 500, 220, 0, 0) }
1167
1191
  }
1168
1192
 
1169
1193
  override fun sendDoubleTextWall(top: String, bottom: String) {
@@ -1179,12 +1203,9 @@ class Nimo : SGCManager() {
1179
1203
  borderWidth: Int,
1180
1204
  borderRadius: Int
1181
1205
  ) {
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
1206
+ submitCanvas("legacy") {
1207
+ canvasText(text, x, y, width, height, borderWidth, borderRadius)
1208
+ }
1188
1209
  }
1189
1210
 
1190
1211
  override fun displayBitmap(
@@ -1194,57 +1215,18 @@ class Nimo : SGCManager() {
1194
1215
  width: Int?,
1195
1216
  height: Int?
1196
1217
  ): 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
1218
  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
1219
+ val left = x ?: 0; val top = y ?: 0
1220
+ val w = width ?: (NimoCanvasCodec.WIDTH - left)
1221
+ val h = height ?: (NimoCanvasCodec.HEIGHT - top)
1222
+ NimoCanvasCodec.region(left, top, w, h)
1223
+ // The Boolean acknowledges bounded local validation, never visible rendering.
1224
+ // Decode once now; the background encoder owns/recycles this same source.
1225
+ val source = decodeCanvasSource(base64ImageData)
1226
+ encodeCanvas("legacy", release = { source.recycle() }) {
1227
+ NimoCanvasCodec.replace(listOf(NimoCanvasCodec.bitmap(left, top, w, h,
1228
+ bitmapToGrayscale(source, w, h))))
1226
1229
  }
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
1230
  } catch (e: Exception) {
1249
1231
  Bridge.log("NIMO: displayBitmap failed: ${e.message}")
1250
1232
  false
@@ -1252,19 +1234,60 @@ class Nimo : SGCManager() {
1252
1234
  }
1253
1235
 
1254
1236
  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
- )
1237
+ exit()
1238
+ }
1239
+
1240
+ override fun applySceneFrame(frame: SceneFrame) {
1241
+ // Serialize the complete ordered scene, not the host differ's annotations.
1242
+ encodeCanvas("${frame.appId}:${frame.epoch}", frame.replay) {
1243
+ NimoCanvasCodec.scene(frame.elements.map { element ->
1244
+ NimoCanvasCodec.Element(element.type, element.x, element.y, element.w, element.h,
1245
+ element.text, element.data, element.border, element.radius)
1246
+ }, ::decodeCanvasImage)
1247
+ }
1248
+ }
1249
+
1250
+ override fun clearSceneElements(elementIds: List<String>) {
1251
+ // Dynamic Layout replaces a complete scene; there is no persistent per-ID state.
1252
+ clearDisplay()
1253
+ }
1254
+
1255
+ private fun canvasText(text: String, x: Int, y: Int, width: Int, height: Int,
1256
+ border: Int, radius: Int): List<NimoCanvasCodec.Object> {
1257
+ require(border in 0..32 && radius in 0..255)
1258
+ val objects = mutableListOf<NimoCanvasCodec.Object>()
1259
+ if (border > 0) objects.add(NimoCanvasCodec.rectangle(x, y, width, height, border, radius))
1260
+ // Legacy callers may send an unwrapped paragraph. Scene IR uses textRows directly.
1261
+ if (text.isNotEmpty() && !text.contains('\n')) {
1262
+ objects.add(NimoCanvasCodec.label(text, x, y, width, height))
1263
+ } else objects.addAll(NimoCanvasCodec.textRows(text, x, y, width, height))
1264
+ return objects
1265
+ }
1266
+
1267
+ /** Keep only the latest pending encode; completion cannot overwrite a newer scene or Exit. */
1268
+ private fun submitCanvas(scope: String, force: Boolean = false,
1269
+ objects: () -> List<NimoCanvasCodec.Object>) {
1270
+ encodeCanvas(scope, force) { NimoCanvasCodec.replace(objects()) }
1271
+ }
1272
+
1273
+ private fun encodeCanvas(scope: String, force: Boolean = false, release: () -> Unit = {},
1274
+ encode: () -> ByteArray): Boolean =
1275
+ canvasEncoder.submit(encode, release) { bytes -> canvas.offer(bytes, scope, force) }
1276
+
1277
+ private fun decodeCanvasSource(data: String): Bitmap {
1278
+ val bytes = NimoCanvasCodec.imageBytes(data)
1279
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
1280
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
1281
+ require(bounds.outWidth in 1..4096 && bounds.outHeight in 1..4096 &&
1282
+ bounds.outWidth.toLong() * bounds.outHeight <= 4_000_000) { "Image decoded size exceeds limit" }
1283
+ return requireNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size)) { "Invalid image" }
1284
+ }
1285
+
1286
+ private fun decodeCanvasImage(data: String, width: Int, height: Int): ByteArray {
1287
+ val source = decodeCanvasSource(data)
1288
+ return try {
1289
+ bitmapToGrayscale(source, width, height)
1290
+ } finally { source.recycle() }
1268
1291
  }
1269
1292
 
1270
1293
  override fun setDashboardPosition(height: Int, depth: Int) {
@@ -1325,17 +1348,11 @@ class Nimo : SGCManager() {
1325
1348
  }
1326
1349
 
1327
1350
  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
- )
1351
+ canvasEncoder.invalidate()
1352
+ mainHandler.post {
1353
+ diagnostics?.cancelHeldCapture("Explicit host exit preempted held capture", resumeCanvas = false)
1354
+ canvas.exit()
1355
+ }
1339
1356
  }
1340
1357
 
1341
1358
  override fun sendShutdown() {
@@ -1545,23 +1562,16 @@ class Nimo : SGCManager() {
1545
1562
  stopScan()
1546
1563
  lastDeviceName = name
1547
1564
  lastDeviceAddress = device.address
1548
- connectGattLe(device)
1565
+ connectCompanionGatt(device)
1549
1566
  }
1550
1567
 
1551
1568
  /**
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.)
1569
+ * Discover and bond the main device over classic Bluetooth, then use BR/EDR ATT
1570
+ * for Companion service 7033. The separate <name>_BLE ANCS peripheral is not the
1571
+ * canvas data channel. This matches the vendor transport=BREDR connection path.
1563
1572
  */
1564
- private fun connectGattLe(device: android.bluetooth.BluetoothDevice) {
1573
+ private fun connectCompanionGatt(device: android.bluetooth.BluetoothDevice) {
1574
+ if (isDisconnecting || gatt != null) return
1565
1575
  // Classic discovery degrades/aborts connections — always cancel first.
1566
1576
  try {
1567
1577
  bluetoothAdapter?.cancelDiscovery()
@@ -1574,11 +1584,11 @@ class Nimo : SGCManager() {
1574
1584
  android.bluetooth.BluetoothDevice.BOND_NONE
1575
1585
  }
1576
1586
  if (bondState != android.bluetooth.BluetoothDevice.BOND_BONDED) {
1577
- // Bond over classic first (CTKD gives us the LE keys); connect on completion.
1587
+ // Bond over classic first, then open the Companion bearer on completion.
1578
1588
  startBond(device)
1579
1589
  return
1580
1590
  }
1581
- connectLeTransport(device)
1591
+ connectBredrTransport(device)
1582
1592
  }
1583
1593
 
1584
1594
  /**
@@ -1597,7 +1607,7 @@ class Nimo : SGCManager() {
1597
1607
  if (bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
1598
1608
  Bridge.log("NIMO: already bonded with ${device.address} — connecting")
1599
1609
  unregisterBondReceiver()
1600
- connectLeTransport(device)
1610
+ connectBredrTransport(device)
1601
1611
  return
1602
1612
  }
1603
1613
  // Don't fire a second createBond on top of one already in flight (duplicate
@@ -1625,13 +1635,14 @@ class Nimo : SGCManager() {
1625
1635
  false
1626
1636
  }
1627
1637
  if (!started) {
1628
- Bridge.log("NIMO: createBond failed to start — trying LE connect anyway")
1638
+ Bridge.log("NIMO: createBond failed to start — trying Companion BR/EDR connection")
1629
1639
  unregisterBondReceiver()
1630
- connectLeTransport(device)
1640
+ connectBredrTransport(device)
1631
1641
  }
1632
1642
  }
1633
1643
 
1634
- private fun connectLeTransport(device: android.bluetooth.BluetoothDevice) {
1644
+ private fun connectBredrTransport(device: android.bluetooth.BluetoothDevice) {
1645
+ if (isDisconnecting || gatt != null) return
1635
1646
  // GATT over BR/EDR: the glasses pair with a legacy (non-SC) link key, so no LE
1636
1647
  // keys exist (le_linkkey_known:F) and the controller records the device as
1637
1648
  // BR_EDR-only — the 7033 UART service is served over the classic link. This is
@@ -1647,6 +1658,7 @@ class Nimo : SGCManager() {
1647
1658
  gattCallback,
1648
1659
  android.bluetooth.BluetoothDevice.TRANSPORT_BREDR
1649
1660
  )
1661
+ gatt?.let { writes.connected(it) }
1650
1662
  }
1651
1663
 
1652
1664
  private var bondReceiver: android.content.BroadcastReceiver? = null
@@ -1688,7 +1700,7 @@ class Nimo : SGCManager() {
1688
1700
  // (right after pairing) succeeds instead of failing until the
1689
1701
  // user manually retries into the already-bonded fast path.
1690
1702
  mainHandler.postDelayed(
1691
- { connectLeTransport(device) },
1703
+ { connectBredrTransport(device) },
1692
1704
  POST_BOND_SETTLE_MS
1693
1705
  )
1694
1706
  }
@@ -1743,7 +1755,7 @@ class Nimo : SGCManager() {
1743
1755
  return try {
1744
1756
  val device = adapter.getRemoteDevice(address)
1745
1757
  Bridge.log("NIMO: connectByAddress - ${device.name ?: address}")
1746
- connectGattLe(device)
1758
+ connectCompanionGatt(device)
1747
1759
  true
1748
1760
  } catch (e: Exception) {
1749
1761
  Bridge.log("NIMO: connectByAddress failed: ${e.message}")
@@ -1774,18 +1786,19 @@ class Nimo : SGCManager() {
1774
1786
  object : BluetoothGattCallback() {
1775
1787
  override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
1776
1788
  mainHandler.post {
1789
+ if (g !== gatt) return@post
1777
1790
  Bridge.log("NIMO: onConnectionStateChange status=$status newState=$newState")
1778
- if (newState == BluetoothProfile.STATE_CONNECTED) {
1791
+ if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) {
1779
1792
  Bridge.log("NIMO: Connected to ${g.device?.name ?: "unknown"}")
1780
1793
  lastDeviceAddress = g.device?.address
1781
1794
  // Do NOT request a large MTU here: the vendor transport warns that
1782
1795
  // requesting one during connect makes some Nimo firmwares throw a
1783
1796
  // GATT error and loop reconnecting. The system negotiates the MTU;
1784
- // frames larger than it go out as ATT long writes (WRITE_TYPE_DEFAULT).
1797
+ // canvas fragments fit the currently known writable ATT size.
1785
1798
  try {
1786
- g.discoverServices()
1799
+ if (!g.discoverServices()) abortTransport("Service discovery did not start")
1787
1800
  } catch (e: SecurityException) {
1788
- Bridge.log("NIMO: discoverServices SecurityException: ${e.message}")
1801
+ abortTransport("Service discovery permission denied")
1789
1802
  }
1790
1803
  } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
1791
1804
  Bridge.log("NIMO: Disconnected")
@@ -1802,6 +1815,8 @@ class Nimo : SGCManager() {
1802
1815
  DeviceStore.apply("glasses", "fullyBooted", false)
1803
1816
  DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1804
1817
  startReconnectionTimer()
1818
+ } else if (status != BluetoothGatt.GATT_SUCCESS) {
1819
+ abortTransport("GATT connection failed status=$status")
1805
1820
  }
1806
1821
  }
1807
1822
  }
@@ -1809,21 +1824,19 @@ class Nimo : SGCManager() {
1809
1824
  override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
1810
1825
  Bridge.log("NIMO: onMtuChanged mtu=$mtu status=$status")
1811
1826
  mainHandler.post {
1812
- negotiatedMtu = mtu
1813
- try {
1814
- g.discoverServices()
1815
- } catch (e: SecurityException) {
1816
- Bridge.log("NIMO: discoverServices SecurityException: ${e.message}")
1817
- }
1827
+ if (g !== gatt || status != BluetoothGatt.GATT_SUCCESS) return@post
1828
+ negotiatedMtu = mtu.coerceIn(23, 517)
1818
1829
  }
1819
1830
  }
1820
1831
 
1821
1832
  override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
1822
1833
  if (status != BluetoothGatt.GATT_SUCCESS) {
1823
1834
  Bridge.log("NIMO: onServicesDiscovered failed status=$status")
1835
+ mainHandler.post { if (g === gatt) abortTransport("Service discovery failed status=$status") }
1824
1836
  return
1825
1837
  }
1826
1838
  mainHandler.post {
1839
+ if (g !== gatt) return@post
1827
1840
  val service = g.getService(NimoBLE.SERVICE_UUID)
1828
1841
  if (service == null) {
1829
1842
  // Dump everything so a UUID mismatch is diagnosable from logs.
@@ -1850,8 +1863,9 @@ class Nimo : SGCManager() {
1850
1863
  return@post
1851
1864
  }
1852
1865
  enableNotifications(g, rxChar!!) {
1853
- micChar?.let { mic -> enableNotifications(g, mic) {} }
1854
- startHandshake()
1866
+ val mic = micChar
1867
+ if (mic == null) startHandshake()
1868
+ else enableNotifications(g, mic) { startHandshake() }
1855
1869
  }
1856
1870
  }
1857
1871
  }
@@ -1861,10 +1875,15 @@ class Nimo : SGCManager() {
1861
1875
  g: BluetoothGatt,
1862
1876
  characteristic: BluetoothGattCharacteristic
1863
1877
  ) {
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)
1878
+ val data = characteristic.value?.copyOf() ?: return
1879
+ mainHandler.post {
1880
+ if (g !== gatt) return@post
1881
+ when (characteristic.uuid) {
1882
+ NimoBLE.CHAR_RX -> if (characteristic.service?.uuid == NimoBLE.SERVICE_UUID) {
1883
+ handleRxPacket(data)
1884
+ }
1885
+ NimoBLE.CHAR_MIC -> audioClient.enqueue(data)
1886
+ }
1868
1887
  }
1869
1888
  }
1870
1889
 
@@ -1873,13 +1892,9 @@ class Nimo : SGCManager() {
1873
1892
  characteristic: BluetoothGattCharacteristic,
1874
1893
  status: Int
1875
1894
  ) {
1876
- mainHandler.postDelayed(
1877
- {
1878
- writeInFlight = false
1879
- drainWriteQueue()
1880
- },
1881
- NimoBLE.INTER_FRAME_DELAY_MS
1882
- )
1895
+ mainHandler.post {
1896
+ writes.written(g, characteristic, status == BluetoothGatt.GATT_SUCCESS)
1897
+ }
1883
1898
  }
1884
1899
 
1885
1900
  override fun onDescriptorWrite(
@@ -1887,78 +1902,38 @@ class Nimo : SGCManager() {
1887
1902
  descriptor: BluetoothGattDescriptor,
1888
1903
  status: Int
1889
1904
  ) {
1890
- mainHandler.post { descriptorWriteCompletion?.let { it() } }
1905
+ mainHandler.post {
1906
+ writes.descriptorWritten(g, descriptor, status == BluetoothGatt.GATT_SUCCESS)
1907
+ }
1891
1908
  }
1892
1909
  }
1893
1910
 
1894
- private var descriptorWriteCompletion: (() -> Unit)? = null
1895
-
1896
1911
  @Suppress("deprecation")
1897
1912
  private fun enableNotifications(
1898
1913
  g: BluetoothGatt,
1899
1914
  characteristic: BluetoothGattCharacteristic,
1900
1915
  onComplete: () -> Unit
1901
1916
  ) {
1902
- g.setCharacteristicNotification(characteristic, true)
1917
+ if (g !== gatt || !g.setCharacteristicNotification(characteristic, true)) {
1918
+ abortTransport("Could not enable local notifications")
1919
+ return
1920
+ }
1903
1921
  val descriptor = characteristic.getDescriptor(NimoBLE.CLIENT_CHARACTERISTIC_CONFIG)
1904
1922
  if (descriptor != null) {
1905
- descriptorWriteCompletion = {
1906
- descriptorWriteCompletion = null
1907
- onComplete()
1908
- }
1909
1923
  descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
1910
- g.writeDescriptor(descriptor)
1924
+ writes.subscribe(g, descriptor, { g.writeDescriptor(descriptor) }, onComplete)
1911
1925
  } else {
1912
- onComplete()
1926
+ abortTransport("Notification descriptor is missing")
1913
1927
  }
1914
1928
  }
1915
1929
 
1916
1930
  // ---------- Write Queue ----------
1917
1931
 
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
1932
  private fun enqueueWrite(char: BluetoothGattCharacteristic, bytes: ByteArray) {
1933
+ val expectedGatt = gatt ?: return
1959
1934
  mainHandler.post {
1960
- writeQueue.addLast(QueuedWrite(char, bytes))
1961
- drainWriteQueue()
1935
+ if (gatt !== expectedGatt) return@post
1936
+ writes.enqueue(expectedGatt, char, listOf(bytes))
1962
1937
  }
1963
1938
  }
1964
1939
 
@@ -1967,14 +1942,25 @@ class Nimo : SGCManager() {
1967
1942
  enqueueWrite(tx, frame)
1968
1943
  }
1969
1944
 
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
- }
1945
+ private fun enqueueFrames(frames: List<ByteArray>, finalStarted: () -> Unit,
1946
+ completed: () -> Unit): Boolean {
1947
+ val tx = txChar ?: return false
1948
+ val connection = gatt ?: return false
1949
+ // Coordinator and GATT callbacks are Main-confined: enqueue the complete chain atomically.
1950
+ return writes.enqueue(connection, tx, frames, finalStarted, completed)
1951
+ }
1952
+
1953
+ private fun abortTransport(reason: String) {
1954
+ Bridge.log("NIMO: resetting transport: $reason")
1955
+ val old = gatt
1956
+ gatt = null
1957
+ txChar = null; rxChar = null; micChar = null
1958
+ resetSessionState()
1959
+ try { old?.disconnect(); old?.close() } catch (_: Exception) {}
1960
+ DeviceStore.apply("glasses", "connected", false)
1961
+ DeviceStore.apply("glasses", "fullyBooted", false)
1962
+ DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1963
+ if (!isDisconnecting) startReconnectionTimer()
1978
1964
  }
1979
1965
 
1980
1966
  // ---------- Pending ACKs ----------
@@ -1993,7 +1979,8 @@ class Nimo : SGCManager() {
1993
1979
  it.onResult(false)
1994
1980
  }
1995
1981
  val timeout = Runnable {
1996
- pendingAcks.remove(ackKey)?.onResult(false)
1982
+ // Do not retry a timed-out key on the same transport: a late response is uncorrelated.
1983
+ if (pendingAcks.remove(ackKey) != null) abortTransport("Handshake ACK timeout cmd=$cmd key=$key")
1997
1984
  }
1998
1985
  pendingAcks[ackKey] = PendingAck(onResult, timeout)
1999
1986
  mainHandler.postDelayed(timeout, timeoutMs)
@@ -2057,12 +2044,14 @@ class Nimo : SGCManager() {
2057
2044
 
2058
2045
  private fun attemptSetTime() {
2059
2046
  if (handshakeState != HandshakeState.AWAITING_TIME_ACK || gatt == null) return
2047
+ val expectedGatt = gatt
2060
2048
  setTimeAttempts++
2061
2049
  sendAwaitingAck(
2062
2050
  NimoProtocol.CMD_SET_PARAMETER,
2063
2051
  NimoProtocol.SET_TIME,
2064
2052
  NimoFrameCodec.encodeDeviceTime()
2065
2053
  ) { ok ->
2054
+ if (gatt !== expectedGatt || handshakeState != HandshakeState.AWAITING_TIME_ACK) return@sendAwaitingAck
2066
2055
  if (ok) {
2067
2056
  finishHandshake()
2068
2057
  } else if (setTimeAttempts < SET_TIME_MAX_ATTEMPTS &&
@@ -2072,7 +2061,7 @@ class Nimo : SGCManager() {
2072
2061
  // The firmware can answer "busy" right after the link comes up —
2073
2062
  // give it a moment and retry instead of dropping the connection.
2074
2063
  Bridge.log("NIMO: setTime attempt $setTimeAttempts failed — retrying")
2075
- mainHandler.postDelayed({ attemptSetTime() }, SET_TIME_RETRY_DELAY_MS)
2064
+ mainHandler.postDelayed({ if (gatt === expectedGatt) attemptSetTime() }, SET_TIME_RETRY_DELAY_MS)
2076
2065
  } else {
2077
2066
  Bridge.log("NIMO: setTime failed after $setTimeAttempts attempts — handshake failed")
2078
2067
  handshakeFailed()
@@ -2100,6 +2089,17 @@ class Nimo : SGCManager() {
2100
2089
  DeviceStore.apply("glasses", "fullyBooted", true)
2101
2090
  DeviceStore.apply("glasses", "connectionState", ConnTypes.CONNECTED)
2102
2091
  startTimers()
2092
+ if (diagnostics == null) {
2093
+ diagnostics = NimoDiagnostics(
2094
+ context,
2095
+ mainHandler,
2096
+ { frame, completed -> enqueueFrames(listOf(frame), {}, completed) },
2097
+ { ready -> canvas.hold(ready) },
2098
+ { resume -> canvas.releaseHold(resume) },
2099
+ )
2100
+ }
2101
+ diagnostics?.connected((negotiatedMtu - 3).coerceIn(20, 512))
2102
+ canvas.readiness(twsConnected && peerCompanionReady == true)
2103
2103
  }
2104
2104
 
2105
2105
  private fun handshakeFailed() {
@@ -2109,18 +2109,17 @@ class Nimo : SGCManager() {
2109
2109
  }
2110
2110
 
2111
2111
  private fun resetSessionState() {
2112
+ diagnostics?.disconnected()
2112
2113
  handshakeState = HandshakeState.IDLE
2114
+ cancelTwsTimeout()
2113
2115
  twsConnected = false
2114
- textAppEntered = false
2115
- navAppEntered = false
2116
- currentGlassesAppId = -1
2117
- pendingText = null
2116
+ peerCompanionReady = null
2117
+ canvasEncoder.invalidate()
2118
+ canvas.disconnected()
2118
2119
  audioClient.stop()
2119
2120
  receiveAssembler.reset()
2120
- writeQueue.clear()
2121
- writeInFlight = false
2122
- writeWatchdog?.let { mainHandler.removeCallbacks(it) }
2123
- writeWatchdog = null
2121
+ writes.reset()
2122
+ negotiatedMtu = 23
2124
2123
  failAllPendingAcks()
2125
2124
  stopTimers()
2126
2125
  }
@@ -2142,7 +2141,7 @@ class Nimo : SGCManager() {
2142
2141
  }
2143
2142
  }
2144
2143
 
2145
- // ---------- Timers (battery poll keepalive + text queue) ----------
2144
+ // ---------- Timers (battery poll keepalive) ----------
2146
2145
 
2147
2146
  private fun startTimers() {
2148
2147
  stopTimers()
@@ -2158,123 +2157,30 @@ class Nimo : SGCManager() {
2158
2157
  batteryPollRunnable = battery
2159
2158
  mainHandler.postDelayed(battery, BATTERY_POLL_MS)
2160
2159
 
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
2160
  }
2171
2161
 
2172
2162
  private fun stopTimers() {
2173
2163
  batteryPollRunnable?.let { mainHandler.removeCallbacks(it) }
2174
2164
  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
2165
  }
2267
2166
 
2268
2167
  // ---------- Incoming Data ----------
2269
2168
 
2270
2169
  private fun handleRxPacket(packet: ByteArray) {
2170
+ // Diagnostic packets must retain their original wire CRC and framing.
2171
+ if (diagnostics?.onPacket(packet) == true) return
2172
+ if (packet.size >= 10 && packet[8].toInt() == 7 && (packet[9].toInt() and 255) in listOf(1, 3, 4)) {
2173
+ val response = NimoCanvasCodec.response(packet) ?: return
2174
+ canvas.response(response.first, response.second)
2175
+ return
2176
+ }
2271
2177
  receiveAssembler.cleanup()
2272
2178
  for (frame in receiveAssembler.ingest(packet)) {
2273
2179
  val decoded = NimoFrameCodec.decode(frame) ?: continue
2274
2180
  val cmd = decoded.cmd ?: continue
2275
2181
  val key = decoded.key ?: continue
2276
2182
  if (cmd == NimoProtocol.CMD_INSTRUCTION_REPORT) {
2277
- handleReport(key, decoded.data ?: ByteArray(0))
2183
+ if (decoded.statusCode == 0) handleReport(key, decoded.data ?: ByteArray(0))
2278
2184
  } else {
2279
2185
  handleResponse(cmd, key, decoded.statusCode ?: 1, decoded.data ?: ByteArray(0))
2280
2186
  }
@@ -2293,14 +2199,16 @@ class Nimo : SGCManager() {
2293
2199
  Bridge.log("NIMO: app state report appId=$appId phase=$phase")
2294
2200
  when (phase) {
2295
2201
  NimoProtocol.STATE_ENTER -> {
2296
- currentGlassesAppId = appId
2297
- if (appId != textAppId) textAppEntered = false
2298
- if (appId != NimoProtocol.APP_ID_NAV) navAppEntered = false
2202
+ if (appId != NimoCanvasCodec.APP_ID) {
2203
+ diagnostics?.cancelHeldCapture("Native app takeover preempted held capture", resumeCanvas = false)
2204
+ }
2205
+ if (canvas.nativeApp(appId, true)) canvasEncoder.invalidate()
2299
2206
  }
2300
2207
  NimoProtocol.STATE_EXIT -> {
2301
- if (appId == currentGlassesAppId) currentGlassesAppId = -1
2302
- if (appId == textAppId) textAppEntered = false
2303
- if (appId == NimoProtocol.APP_ID_NAV) navAppEntered = false
2208
+ if (appId == NimoCanvasCodec.APP_ID) {
2209
+ diagnostics?.cancelHeldCapture("Canvas exit preempted held capture", resumeCanvas = false)
2210
+ }
2211
+ if (canvas.nativeApp(appId, false)) canvasEncoder.invalidate()
2304
2212
  }
2305
2213
  }
2306
2214
  }
@@ -2311,7 +2219,10 @@ class Nimo : SGCManager() {
2311
2219
  }
2312
2220
  }
2313
2221
  NimoProtocol.REPORT_BUSINESS -> handleBusinessReport(data)
2314
- NimoProtocol.REPORT_GATT_STATE -> {}
2222
+ NimoProtocol.REPORT_GATT_STATE -> {
2223
+ // 0605's payload is undocumented. Only the heartbeat's defined
2224
+ // slaveGatt field can establish peer Companion readiness.
2225
+ }
2315
2226
  else -> Bridge.log("NIMO: unknown report key=$key")
2316
2227
  }
2317
2228
  }
@@ -2324,7 +2235,13 @@ class Nimo : SGCManager() {
2324
2235
  NimoProtocol.BUSINESS_HEARTBEAT -> {
2325
2236
  // [leftMv(2)][rightMv(2)][btSysStatus(4)][twsStatus(1)][slaveGatt(1)]
2326
2237
  if (v.size >= 10) {
2238
+ peerCompanionReady = v[9].toInt() != 0
2327
2239
  onTwsState((v[8].toInt() and 0xFF) >= 1)
2240
+ // Both predicates were sampled by this device report. TWS-only
2241
+ // reports and cached state cannot release a status-7 wait.
2242
+ if (handshakeState == HandshakeState.READY) {
2243
+ canvas.confirmedReadiness(twsConnected && peerCompanionReady == true)
2244
+ }
2328
2245
  }
2329
2246
  }
2330
2247
  NimoProtocol.BUSINESS_BATTERY -> {
@@ -2349,6 +2266,7 @@ class Nimo : SGCManager() {
2349
2266
  if (!connected && handshakeState == HandshakeState.READY) {
2350
2267
  Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
2351
2268
  }
2269
+ if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady == true)
2352
2270
  }
2353
2271
 
2354
2272
  private fun handleInputEvent(code: Int) {
@@ -2448,20 +2366,15 @@ class Nimo : SGCManager() {
2448
2366
 
2449
2367
  // ---------- Bitmap Helpers ----------
2450
2368
 
2451
- /** Aspect-fit the bitmap into [width]x[height] on black, then convert to L8 grayscale. */
2369
+ /** Scale into the exact layout box, compositing alpha over black, then convert to L8. */
2452
2370
  private fun bitmapToGrayscale(source: Bitmap, width: Int, height: Int): ByteArray {
2453
2371
  val scaled = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
2454
2372
  val canvas = Canvas(scaled)
2455
2373
  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
2374
  canvas.drawBitmap(
2462
2375
  source,
2463
2376
  null,
2464
- Rect(left, top, left + dw, top + dh),
2377
+ Rect(0, 0, width, height),
2465
2378
  Paint(Paint.FILTER_BITMAP_FLAG)
2466
2379
  )
2467
2380
 
@@ -2471,62 +2384,11 @@ class Nimo : SGCManager() {
2471
2384
  for (i in pixels.indices) {
2472
2385
  val p = pixels[i]
2473
2386
  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()
2387
+ (77 * Color.red(p) + 150 * Color.green(p) + 29 * Color.blue(p) + 128) shr 8
2388
+ gray[i] = luminance.coerceIn(0, 255).toByte()
2481
2389
  }
2482
2390
  scaled.recycle()
2483
2391
  return gray
2484
2392
  }
2485
2393
 
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
2394
  }