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