diff --git a/app/libs/unity-bridge.aar b/app/libs/unity-bridge.aar index df71e2f..8fb521b 100644 Binary files a/app/libs/unity-bridge.aar and b/app/libs/unity-bridge.aar differ diff --git a/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt b/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt index 72e955b..558bc1a 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt @@ -16,6 +16,15 @@ class BridgeRepository { fun getRuntimeStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeStateJson() fun getHealthStateJson(): String = KiwiiRuntimeClientBridge.getHealthStateJson() fun getDebugSnapshotJson(): String = KiwiiRuntimeClientBridge.getDebugSnapshotJson() + fun getDongleStateJson(): String { + return try { + val method = KiwiiRuntimeClientBridge::class.java.getMethod("getDongleStateJson") + method.invoke(null) as? String ?: getDebugSnapshotJson() + } catch (_: Throwable) { + // Backward-compatible fallback for old unity-bridge.aar builds. + getDebugSnapshotJson() + } + } fun getRuntimeLifecycleStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeLifecycleStateJson() fun requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason) @@ -27,15 +36,23 @@ class BridgeRepository { fun getLatestPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestPoseFrameJson() fun getLatestBodyPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestBodyPoseFrameJson() - fun getLeftHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLeftHandleIMULatest() - fun getLeftHandleIMUQueue(): Array = KiwiiRuntimeClientBridge.getLeftHandleIMUQueue() + fun getLeftHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLatestLeftHandleIMU() + fun getLeftHandleIMUQueue(): Array { + val latest = KiwiiRuntimeClientBridge.getLatestLeftHandleIMU() + return if (latest.isEmpty()) emptyArray() else arrayOf(latest) + } fun submitLeftStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitLeftStaticEffect(effectId) - fun submitLeftHeStream(streamId: Int, payload: String): String = KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, payload) + fun submitLeftHeStream(streamId: Int, payload: String): String = + KiwiiRuntimeClientBridge.submitLeftHeStream(buildHeStreamPatternJson(streamId, payload)) - fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest() - fun getRightHandleIMUQueue(): Array = KiwiiRuntimeClientBridge.getRightHandleIMUQueue() + fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLatestRightHandleIMU() + fun getRightHandleIMUQueue(): Array { + val latest = KiwiiRuntimeClientBridge.getLatestRightHandleIMU() + return if (latest.isEmpty()) emptyArray() else arrayOf(latest) + } fun submitRightStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitRightStaticEffect(effectId) - fun submitRightHeStream(streamId: Int, payload: String): String = KiwiiRuntimeClientBridge.submitRightHeStream(streamId, payload) + fun submitRightHeStream(streamId: Int, payload: String): String = + KiwiiRuntimeClientBridge.submitRightHeStream(buildHeStreamPatternJson(streamId, payload)) fun getHandleStateJson(): String = KiwiiRuntimeClientBridge.getHandleStateJson() @@ -51,6 +68,13 @@ class BridgeRepository { fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command) fun getDeviceCommandStateJson(): String = KiwiiRuntimeClientBridge.getDeviceCommandStateJson() + private fun buildHeStreamPatternJson(streamId: Int, payload: String): String { + val trimmed = payload.trim() + if (trimmed.startsWith("{")) return trimmed + val escaped = payload.replace("\\", "\\\\").replace("\"", "\\\"") + return "{\"streamId\":$streamId,\"payload\":\"$escaped\"}" + } + suspend fun callAsync(block: () -> T): OperationResult = withContext(Dispatchers.IO) { try { val result = block() diff --git a/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt b/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt index 2150080..dff60df 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt @@ -25,6 +25,16 @@ class ComponentRegistry( private var previousStates = emptyMap() + private companion object { + const val MOTOR_TELEMETRY_FRESH_TIMEOUT_MS = 5000L + } + + // Motor is physically downstream of the SmartBase Dongle. + // Keep the latest Dongle card decision from the same detection loop so Motor cannot remain green + // after the Dongle transport is disconnected, while preserving v4 Dongle detection behavior. + @Volatile + private var latestDongleCardStateForMotorGate: ComponentState = ComponentState.GREY + fun startDetection() { scope.launch(Dispatchers.IO) { while (isActive) { @@ -95,11 +105,12 @@ class ComponentRegistry( private fun dumpDebugOnce() { if (debugDumped || !repo.isBound()) return debugDumped = true + // 首页首次检测不要调用 getDebugSnapshotJson / camera / motor 等重 API。 + // 这些 API 会触发 native snapshot 或 camera state 路径;RuntimeHost 刚启动时容易与 + // native init/camera warm-start 形成 Binder 等待,导致 NOT_BOUND 或 service ANR。 val apis = mapOf( - "getCameraStateJson" to runCatching { repo.getCameraStateJson() }.getOrDefault("EXCEPTION"), - "getLatestMotorStateJson" to runCatching { repo.getLatestMotorStateJson() }.getOrDefault("EXCEPTION"), - "getDebugSnapshotJson(first200)" to runCatching { repo.getDebugSnapshotJson().take(200) }.getOrDefault("EXCEPTION"), - "getHandleStateJson" to runCatching { repo.getHandleStateJson() }.getOrDefault("EXCEPTION"), + "isBound" to repo.isBound().toString(), + "getDongleStateJson(first400)" to runCatching { repo.getDongleStateJson().take(400) }.getOrDefault("EXCEPTION") ) for ((name, value) in apis) { logger.log(LogEntry( @@ -153,12 +164,32 @@ class ComponentRegistry( } private fun checkDongle(): ComponentState { + val state = computeDongleState() + latestDongleCardStateForMotorGate = state + return state + } + + private fun computeDongleState(): ComponentState { return try { - // Dongle 是 USB 外设,状态在 getDebugSnapshotJson 的全量数据中 - val json = repo.getDebugSnapshotJson() + val json = repo.getDongleStateJson() if (isUnavailable(json)) return ComponentState.GREY - // dongleState 字段存在说明 Dongle USB 已连接并被 RuntimeHost 识别 - if (json.contains("\"dongleState\"")) { + val obj = JSONObject(json) + val transport = obj.optJSONObject("transport") ?: return ComponentState.GREY + + val active = obj.optBoolean("active", false) || obj.optBoolean("available", false) || obj.optBoolean("connected", false) + val transportActive = transport.optBoolean("active", false) || transport.optBoolean("connected", false) + val usbBridgeRegistered = transport.optBoolean("usbBridgeRegistered", false) + val readLoopRunning = transport.optBoolean("readLoopRunning", false) + val usbPresent = transport.optBoolean("usbPresent", false) + val state = transport.optString("state", "") + + // Dongle 首页卡片表达“USB transport 已经被 RuntimeHost 打开并注册”, + // 不要求 lastUsbInAgeMs < 3s。是否有上行数据滞后由 detail page 的 dataStale/lastUsbInAgeMs 显示。 + if ((active || transportActive || state == "CONNECTED") && + usbPresent && + usbBridgeRegistered && + readLoopRunning + ) { ComponentState.ACTIVE } else { ComponentState.GREY @@ -170,11 +201,23 @@ class ComponentRegistry( private fun checkMotor(): ComponentState { return try { + // Motor is physically downstream of the SmartBase Dongle. + // V7 requires fresh motor telemetry; stale cached telemetry after Dongle re-plug must not turn Motor green. + if (latestDongleCardStateForMotorGate != ComponentState.ACTIVE) return ComponentState.GREY + val json = repo.getLatestMotorStateJson() if (isUnavailable(json)) return ComponentState.GREY - // motor-runtime-state.v1 has top-level "available" field + val obj = JSONObject(json) - if (obj.optBoolean("available", false)) ComponentState.ACTIVE else ComponentState.GREY + if (obj.optString("contractVersion", "") != "kiwii.motor-runtime-state.v1") return ComponentState.GREY + if (obj.optBoolean("blockedByDongleTransport", false)) return ComponentState.GREY + if (obj.optBoolean("blockedByMotorTelemetry", false)) return ComponentState.GREY + if (!obj.optBoolean("available", false)) return ComponentState.GREY + + val dataAgeMs = obj.optLong("dataAgeMs", Long.MAX_VALUE) + if (dataAgeMs < 0L || dataAgeMs > MOTOR_TELEMETRY_FRESH_TIMEOUT_MS) return ComponentState.GREY + + ComponentState.ACTIVE } catch (_: Exception) { ComponentState.GREY } diff --git a/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateSource.kt b/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateSource.kt index 135e9cb..c892bff 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateSource.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateSource.kt @@ -15,7 +15,7 @@ class AarProxyStateSource(private val bridge: BridgeRepository) : RuntimeStateSo ComponentType.LEFT_HANDLE, ComponentType.RIGHT_HANDLE -> bridge.getHandleStateJson() ComponentType.MOTOR -> bridge.getLatestMotorStateJson() ComponentType.BALANCE_BOARD -> bridge.getDebugSnapshotJson() - ComponentType.DONGLE -> bridge.getDebugSnapshotJson() + ComponentType.DONGLE -> bridge.getDongleStateJson() ComponentType.TELEMETRY_SAFETY -> bridge.getTelemetryStateJson() ComponentType.SESSION_LOG -> "{}" } diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/DongleOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/DongleOps.kt index 91ea41d..7af475d 100644 --- a/app/src/main/java/com/kiwii/controlpanel/ui/components/DongleOps.kt +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/DongleOps.kt @@ -2,20 +2,44 @@ package com.kiwii.controlpanel.ui.components import android.content.Context import android.view.View -import android.widget.TextView -import androidx.core.content.ContextCompat -import com.kiwii.controlpanel.R +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel -class DongleOps(context: Context) : BaseOps(context) { +class DongleOps( + context: Context, + private val viewModel: ComponentDetailViewModel +) : BaseOps(context) { override fun createView(): View { val layout = verticalLayout() - layout.addView(TextView(context).apply { - text = "暂无 SDK API" - textSize = 12f - setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle)) - setPadding(0, 32, 0, 32) + + layout.addView(createSection("Dongle Transport")) + layout.addView(createButton("getDongleStateJson") { + viewModel.executeOperation(ComponentType.DONGLE, "getDongleStateJson") { + viewModel.bridgeRepo.getDongleStateJson() + } }) + + layout.addView(createButton("getDebugSnapshotJson") { + viewModel.executeOperation(ComponentType.DONGLE, "getDebugSnapshotJson") { + viewModel.bridgeRepo.getDebugSnapshotJson() + } + }) + + layout.addView(createSection("Transport Probe")) + layout.addView(createButton("queryMotorTrainingMode") { + viewModel.executeOperation(ComponentType.DONGLE, "queryMotorTrainingMode", "axis=0") { + viewModel.bridgeRepo.queryMotorTrainingMode(0) + } + }) + + layout.addView(createSection("Expected fields")) + layout.addView(createButton("Inspect transport + slots") { + viewModel.executeOperation(ComponentType.DONGLE, "inspectDongleDiagnostics") { + viewModel.bridgeRepo.getDongleStateJson() + } + }) + return layout } }