@mentra/bluetooth-sdk 3.2.1-beta.319 → 3.2.1-beta.355
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +67 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
- package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
- package/build/BluetoothSdk.types.d.ts +11 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +3 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +5 -0
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothSdkDefaults.swift +6 -1
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +23 -10
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +4 -0
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
- package/ios/Source/sgcs/G2.swift +2 -2
- package/ios/Source/sgcs/MentraLive.swift +66 -3
- package/ios/Source/sgcs/Nimo.swift +182 -371
- package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
- package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
- package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
- package/ios/Source/sgcs/SGCManager.swift +24 -0
- package/ios/Source/utils/G2Text.swift +11 -0
- package/ios/Tests/DashboardRenderingTests.swift +31 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
- package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +11 -0
- package/src/_private/BluetoothSdkModule.ts +6 -0
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- 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
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
//
|
|
915
|
-
private
|
|
916
|
-
private val
|
|
917
|
-
private val
|
|
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
|
-
//
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
1154
|
-
sendTextWall(" ")
|
|
1182
|
+
submitCanvas("legacy") { emptyList() }
|
|
1155
1183
|
}
|
|
1156
1184
|
|
|
1157
1185
|
override fun sendText(text: String) {
|
|
1158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
|
1208
|
-
val
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
-
|
|
1565
|
+
connectCompanionGatt(device)
|
|
1549
1566
|
}
|
|
1550
1567
|
|
|
1551
1568
|
/**
|
|
1552
|
-
*
|
|
1553
|
-
*
|
|
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
|
|
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
|
|
1587
|
+
// Bond over classic first, then open the Companion bearer on completion.
|
|
1578
1588
|
startBond(device)
|
|
1579
1589
|
return
|
|
1580
1590
|
}
|
|
1581
|
-
|
|
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
|
-
|
|
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
|
|
1638
|
+
Bridge.log("NIMO: createBond failed to start — trying Companion BR/EDR connection")
|
|
1629
1639
|
unregisterBondReceiver()
|
|
1630
|
-
|
|
1640
|
+
connectBredrTransport(device)
|
|
1631
1641
|
}
|
|
1632
1642
|
}
|
|
1633
1643
|
|
|
1634
|
-
private fun
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1813
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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.
|
|
1877
|
-
|
|
1878
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1961
|
-
|
|
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
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
pendingText = null
|
|
2116
|
+
peerCompanionReady = null
|
|
2117
|
+
canvasEncoder.invalidate()
|
|
2118
|
+
canvas.disconnected()
|
|
2118
2119
|
audioClient.stop()
|
|
2119
2120
|
receiveAssembler.reset()
|
|
2120
|
-
|
|
2121
|
-
|
|
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
|
|
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
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
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 ==
|
|
2302
|
-
|
|
2303
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
(
|
|
2475
|
-
|
|
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
|
}
|