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 89930b6..228027e 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt @@ -5,6 +5,8 @@ import com.kiwii.bridge.KiwiiRuntimeClientBridge import com.kiwii.controlpanel.model.OperationResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject class BridgeRepository { @@ -29,6 +31,72 @@ class BridgeRepository { fun requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason) fun getCameraStateJson(): String = KiwiiRuntimeClientBridge.getCameraStateJson() + /** + * Phase4B-v11.2 compile fix: keep the Phase4B camera observation API that CameraOps + * and RuntimeStateSource already depend on. v11 overwrote BridgeRepository while adding + * Dongle detail-state fusion and accidentally dropped this method. + */ + fun getCameraObservationStateJson(): String { + return try { + val method = KiwiiRuntimeClientBridge::class.java.getMethod("getCameraObservationStateJson") + method.invoke(null) as? String ?: getCameraStateJson() + } catch (_: Throwable) { + try { + val out = JSONObject() + val camera = parseJsonOrEmpty(getCameraStateJson()) + val health = parseJsonOrEmpty(getHealthStateJson()) + val debug = runCatching { parseJsonOrEmpty(getDebugSnapshotJson()) }.getOrDefault(JSONObject()) + val latestPose2D = runCatching { getLatestPose2D() }.getOrDefault(FloatArray(0)) + val latestPose3D = runCatching { getLatestPose3D() }.getOrDefault(FloatArray(0)) + val runtimeStats = runCatching { getLatestRuntimeStats() }.getOrDefault(FloatArray(0)) + val statsActive = runtimeStats.any { value -> !value.isNaN() && !value.isInfinite() && value > 0.1f } + + val pipeline = health.optJSONObject("realInternalCameraPipeline") + ?: camera.optJSONObject("realInternalCameraPipeline") + ?: debug.optJSONObject("realInternalCameraPipeline") + ?: JSONObject() + val pose2DState = health.optJSONObject("pose2DState") + ?: camera.optJSONObject("pose2DState") + ?: debug.optJSONObject("pose2DState") + ?: JSONObject() + val pose3DState = health.optJSONObject("pose3DState") + ?: camera.optJSONObject("pose3DState") + ?: debug.optJSONObject("pose3DState") + ?: JSONObject() + + out.put("contractVersion", "kiwii.sdk-panel.camera-observation-display.v11.2") + out.put("source", "SDK_Panel.camera-observation-compat") + out.put("pipeline", JSONObject() + .put("state", if (statsActive || latestPose2D.isNotEmpty() || latestPose3D.isNotEmpty()) "RUNNING" else "STARTING") + .put("imx415OpenSucceeded", pipeline.optBoolean("imx415OpenSucceeded", health.optBoolean("imx415OpenSucceeded", false))) + .put("rgaEnabled", pipeline.optBoolean("rgaEnabled", health.optBoolean("rgaEnabled", false))) + .put("rknnYoloEnabled", pipeline.optBoolean("rknnYoloEnabled", health.optBoolean("rknnYoloEnabled", false))) + .put("onnxInferenceEnabled", pipeline.optBoolean("onnxInferenceEnabled", health.optBoolean("onnxInferenceEnabled", false))) + .put("videoPose3DEnabled", pipeline.optBoolean("videoPose3DEnabled", health.optBoolean("videoPose3DEnabled", false))) + ) + out.put("pose2DLatest", JSONObject() + .put("available", pose2DState.optBoolean("available", latestPose2D.isNotEmpty())) + .put("sequenceId", pose2DState.optLong("pose2DSequenceId", 0L)) + .put("arrayLength", latestPose2D.size) + ) + out.put("pose3DLatest", JSONObject() + .put("available", pose3DState.optBoolean("available", latestPose3D.isNotEmpty())) + .put("sequenceId", pose3DState.optLong("pose3DSequenceId", 0L)) + .put("arrayLength", latestPose3D.size) + .put("pose3DModelFrames", pose3DState.optInt("pose3DModelFrames", pipeline.optInt("pose3DModelFrames", health.optInt("pose3DModelFrames", 27)))) + ) + out.put("runtimeStatsActive", statsActive) + out.put("rawCameraState", camera) + out.toString() + } catch (t: Throwable) { + JSONObject() + .put("contractVersion", "kiwii.sdk-panel.camera-observation-display.v11.2") + .put("error", t.javaClass.simpleName + ": " + (t.message ?: "unknown")) + .toString() + } + } + } + fun getPerceptionStateJson(): String = KiwiiRuntimeClientBridge.getPerceptionStateJson() fun getLatestPose2D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose2D() fun getLatestPose3D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose3D() @@ -100,6 +168,221 @@ class BridgeRepository { fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command) fun getDeviceCommandStateJson(): String = KiwiiRuntimeClientBridge.getDeviceCommandStateJson() + + /** + * Phase4B-v11.5 SDK Panel safe detail-state view for Dongle. + * + * Important design decision: this UI path must NOT call raw getDongleStateJson(). + * On current RuntimeHost builds that raw dongle query can block behind debug/global + * state paths, causing the Dongle detail State panel to disappear and readDongleState + * to return only a timeout. + * + * For the detail page we therefore use the non-blocking HandleState contract as the + * operational evidence source and explicitly mark raw dongle slot/transport report as + * not queried on this safe UI path. RuntimeHost/SmartBase transport health is still + * validated by the home-card availability poll and logcat; this page is a per-component + * diagnostic view and must remain responsive. + */ + fun getDongleDetailStateJson(): String { + val handleRaw = try { + getHandleStateJson() + } catch (t: Throwable) { + JSONObject() + .put("error", "getHandleStateJson failed: ${escapeJson(t.message ?: t.javaClass.simpleName)}") + .toString() + } + return buildDongleDetailStateJson(buildSafeDonglePlaceholderJson(), handleRaw) + } + + private fun buildSafeDonglePlaceholderJson(): String { + return JSONObject() + .put("contractVersion", "kiwii.sdk-panel.dongle-safe-placeholder.v11.5") + .put("source", "SDK_Panel.non-blocking-dongle-detail") + .put("rawDongleQuery", "disabled-on-detail-ui-path") + .put("transport", JSONObject() + .put("state", "NOT_QUERIED") + .put("rawDongleQuery", "disabled-on-detail-ui-path") + .put("detail", "Raw transport query skipped on safe detail path.")) + .toString() + } + + private fun buildDongleDetailStateJson(dongleRaw: String, handleRaw: String): String { + val dongle = parseJsonOrEmpty(dongleRaw) + val handle = parseJsonOrEmpty(handleRaw) + val out = JSONObject() + out.put("contractVersion", "kiwii.sdk-panel.dongle-detail-state.v11.5") + out.put("source", "SDK_Panel.safe-handle-state-dongle-detail") + out.put("diagnosticSemantics", "operational-state-first; raw-dongle-query-disabled-on-safe-ui-path; raw-slot-report-secondary") + out.put("generatedAtMs", System.currentTimeMillis()) + out.put("dongle", dongle) + out.put("handleState", handle) + + val transport = dongle.optJSONObject("transport") ?: JSONObject() + out.put("transport", transport) + out.put("slots", buildSdkPanelSlots(dongle, handle)) + + val evidenceNote = buildSlotEvidenceNote(dongle, handle) + if (evidenceNote.isNotBlank()) out.put("slotEvidenceNote", evidenceNote) + return out.toString() + } + + private fun buildSdkPanelSlots(dongle: JSONObject, handle: JSONObject): JSONArray { + val sourceSlots = firstSlotArray(dongle) + val slots = JSONArray() + val left = handle.optJSONObject("left") ?: JSONObject() + val right = handle.optJSONObject("right") ?: JSONObject() + + for (slotIndex in 0..3) { + val reported = findSlotObject(slotIndex, sourceSlots) + val slot = JSONObject() + slot.put("slot", slotIndex) + + val inferredHandle = when (slotIndex) { + 0 -> if (isFreshHandle(left)) "LEFT_HANDLE" else "" + 1 -> if (isFreshHandle(right)) "RIGHT_HANDLE" else "" + else -> "" + } + + if (inferredHandle.isNotBlank()) { + val handleObj = if (slotIndex == 0) left else right + slot.put("state", "CONNECTED") + slot.put("connected", true) + slot.put("device", inferredHandle) + slot.put("source", "handle-state-inferred") + slot.put("inferred", true) + slot.put("sequenceId", handleObj.optLong("sequenceId", handleObj.optLong("seq", 0L))) + slot.put("dataAgeMs", handleObj.optLong("dataAgeMs", -1L)) + slot.put("queueSize", handleObj.optInt("queueSize", 0)) + val reportedState = reported?.optString("state", reported.optString("status", "")) ?: "" + val rawSlotReport = when { + reported == null -> if (dongle.optString("rawDongleQuery", "") == "disabled-on-detail-ui-path") "not-queried" else "missing" + reportedState.isBlank() -> "present-without-state" + reportedState.equals("CONNECTED", ignoreCase = true) -> "connected" + else -> reportedState.lowercase() + } + slot.put("rawSlotReport", rawSlotReport) + if (reportedState.isNotBlank() && !reportedState.equals("CONNECTED", ignoreCase = true)) { + slot.put("detail", "Connected via handle-state; rawSlot=$rawSlotReport") + } else if (reported == null) { + slot.put("detail", "Connected via handle-state; rawSlot=$rawSlotReport") + } else { + slot.put("detail", "Connected via handle-state") + } + } else if (reported != null) { + val connected = reported.optBooleanFlexible("connected") || + reported.optBooleanFlexible("available") || + reported.optBooleanFlexible("active") + val state = firstNonBlank( + reported.optString("state", ""), + reported.optString("status", ""), + if (connected) "CONNECTED" else "DISCONNECTED" + ) + slot.put("state", state) + slot.put("connected", connected) + slot.put("source", "dongle-slot-report") + slot.put("inferred", false) + copyIfPresent(reported, slot, "dev") + copyIfPresent(reported, slot, "device") + copyIfPresent(reported, slot, "deviceType") + copyIfPresent(reported, slot, "deviceId") + copyIfPresent(reported, slot, "sequenceId") + copyIfPresent(reported, slot, "seq") + copyIfPresent(reported, slot, "dataAgeMs") + copyIfPresent(reported, slot, "ageMs") + slot.put("detail", "Reported by dongle JSON") + } else { + slot.put("state", "UNKNOWN") + slot.put("connected", false) + slot.put("source", "missing-slot-report") + slot.put("inferred", false) + slot.put("detail", if (dongle.optString("rawDongleQuery", "") == "disabled-on-detail-ui-path") "Raw slot not queried" else "No raw slot report") + } + + slots.put(slot) + } + return slots + } + + private fun buildSlotEvidenceNote(dongle: JSONObject, handle: JSONObject): String { + val sourceSlots = firstSlotArray(dongle) + val right = handle.optJSONObject("right") ?: JSONObject() + val rightFresh = isFreshHandle(right) + val reportedSlot1 = findSlotObject(1, sourceSlots) + val reportedSlot1Connected = reportedSlot1?.let { + it.optBooleanFlexible("connected") || it.optBooleanFlexible("available") || it.optBooleanFlexible("active") || + it.optString("state", "").equals("CONNECTED", ignoreCase = true) || + it.optString("status", "").equals("CONNECTED", ignoreCase = true) + } ?: false + return if (rightFresh && !reportedSlot1Connected) { + "Slot 1 inferred from Right Handle IMU; raw slot not queried." + } else { + "" + } + } + + private fun firstSlotArray(obj: JSONObject): JSONArray? { + obj.optJSONArray("slots")?.let { return it } + obj.optJSONArray("slotStatus")?.let { return it } + obj.optJSONArray("slotStates")?.let { return it } + obj.optJSONObject("dongle")?.optJSONArray("slots")?.let { return it } + obj.optJSONObject("status")?.optJSONArray("slots")?.let { return it } + return null + } + + private fun findSlotObject(slotIndex: Int, slots: JSONArray?): JSONObject? { + if (slots == null) return null + for (i in 0 until slots.length()) { + val obj = slots.optJSONObject(i) ?: continue + val reportedIndex = obj.optIntOrNull("slot") ?: obj.optIntOrNull("slotId") ?: obj.optIntOrNull("index") + if (reportedIndex == slotIndex) return obj + } + return slots.optJSONObject(slotIndex) + } + + private fun isFreshHandle(handle: JSONObject): Boolean { + val available = handle.optBooleanFlexible("available") + val connected = handle.optBooleanFlexible("connected") + val streaming = handle.optBooleanFlexible("streaming") + val seq = handle.optLong("sequenceId", handle.optLong("seq", 0L)) + val queueSize = handle.optInt("queueSize", 0) + val age = handle.optLong("dataAgeMs", -1L) + val ageFresh = age in 0..2500 + return (available || connected || streaming) && (ageFresh || (seq > 0L && queueSize > 0)) + } + + private fun parseJsonOrEmpty(raw: String): JSONObject { + return try { + JSONObject(raw) + } catch (_: Throwable) { + JSONObject().put("raw", raw.take(500)) + } + } + + private fun copyIfPresent(src: JSONObject, dst: JSONObject, key: String) { + if (src.has(key)) dst.put(key, src.opt(key)) + } + + private fun JSONObject.optBooleanFlexible(key: String): Boolean { + if (!has(key)) return false + val raw = opt(key) ?: return false + return when (raw) { + is Boolean -> raw + is Number -> raw.toInt() != 0 + is String -> raw.equals("true", ignoreCase = true) || raw == "1" || raw.equals("yes", ignoreCase = true) || raw.equals("connected", ignoreCase = true) || raw.equals("active", ignoreCase = true) || raw.equals("running", ignoreCase = true) + else -> false + } + } + + private fun JSONObject.optIntOrNull(key: String): Int? { + if (!has(key)) return null + return try { optInt(key) } catch (_: Throwable) { null } + } + + private fun firstNonBlank(vararg values: String): String = values.firstOrNull { it.isNotBlank() } ?: "" + + private fun escapeJson(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"") + + private fun readHandleImuQueueCompat(methodName: String): Array { return try { val method = KiwiiRuntimeClientBridge::class.java.getMethod(methodName) 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 dff60df..1e0f75a 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt @@ -26,20 +26,72 @@ class ComponentRegistry( private var previousStates = emptyMap() private companion object { - const val MOTOR_TELEMETRY_FRESH_TIMEOUT_MS = 5000L + const val TAG = "KiwiiSDKPanelState" + const val HANDLE_DATA_AGE_FRESH_TIMEOUT_MS = 1500L + const val HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L + const val DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L + const val DONGLE_RULE = "USB_TRANSPORT_ACTIVE_OR_RECENT_HANDLE_PROGRESS" } - // 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. + private data class HandleSignal( + val side: String, + val available: Boolean = false, + val connected: Boolean = false, + val streaming: Boolean = false, + val sequenceId: Long = 0L, + val dataAgeMs: Long = -1L, + val queueSize: Int = 0, + val health: String = "", + val source: String = "" + ) + + private data class DongleSignal( + val valid: Boolean = false, + val active: Boolean = false, + val usbPresent: Boolean = false, + val usbBridgeRegistered: Boolean = false, + val readLoopRunning: Boolean = false, + val state: String = "", + val source: String = "", + val evidence: String = "" + ) + + private data class HandleDashboardSignal( + val valid: Boolean, + val left: HandleSignal = HandleSignal("left"), + val right: HandleSignal = HandleSignal("right"), + val dongleStateLocated: Boolean = false, + val observationBufferLocated: Boolean = false, + val dongle: DongleSignal = DongleSignal(), + val leftProgressAgeMs: Long = Long.MAX_VALUE, + val rightProgressAgeMs: Long = Long.MAX_VALUE, + val dongleProgressAgeMs: Long = Long.MAX_VALUE + ) + @Volatile private var latestDongleCardStateForMotorGate: ComponentState = ComponentState.GREY + private var lastLeftSequenceId: Long = 0L + private var lastRightSequenceId: Long = 0L + private var lastLeftSequenceProgressWallMs: Long = 0L + private var lastRightSequenceProgressWallMs: Long = 0L + private var lastDongleSequenceProgressWallMs: Long = 0L + fun startDetection() { scope.launch(Dispatchers.IO) { while (isActive) { - val result = ComponentType.entries.associateWith { checkAvailability(it) } - // 检测状态变化,记录日志 + val bound = repo.isBound() + val handleJson = if (bound) runCatching { repo.getHandleStateJson() }.getOrDefault("{\"error\":\"handle_state_exception\"}") else "{\"error\":\"NOT_BOUND\"}" + val runtimeStats = if (bound) runCatching { repo.getLatestRuntimeStats() }.getOrDefault(FloatArray(0)) else FloatArray(0) + val cameraJson = if (bound) runCatching { repo.getCameraStateJson() }.getOrDefault("{\"error\":\"camera_state_exception\"}") else "{\"error\":\"NOT_BOUND\"}" + val dongleJson = if (bound) runCatching { repo.getDongleStateJson() }.getOrDefault("{\"error\":\"dongle_state_exception\"}") else "{\"error\":\"NOT_BOUND\"}" + val handleSignals = updateHandleSignals(bound, handleJson, dongleJson) + + val result = ComponentType.entries.associateWith { type -> + checkAvailability(type, bound, handleJson, runtimeStats, cameraJson, handleSignals) + } + + logDashboardSummary(bound, result, handleSignals, runtimeStats, cameraJson) logStateChanges(previousStates, result) previousStates = result _availability.value = result @@ -48,6 +100,210 @@ class ComponentRegistry( } } + private fun updateHandleSignals(bound: Boolean, handleJson: String, dongleJson: String): HandleDashboardSignal { + val now = System.currentTimeMillis() + val dongle = parseDongleSignal(bound, dongleJson) + if (!bound || isUnavailable(handleJson)) { + return HandleDashboardSignal( + valid = false, + dongle = dongle, + leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs), + rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs), + dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs) + ) + } + return try { + val obj = JSONObject(handleJson) + val left = parseHandleSignal("left", obj.optJSONObject("left")) + val right = parseHandleSignal("right", obj.optJSONObject("right")) + + val leftProgress = updateSequenceProgress( + side = "left", + sequenceId = left.sequenceId, + hasLiveFlags = left.available || left.connected || left.streaming, + now = now + ) + val rightProgress = updateSequenceProgress( + side = "right", + sequenceId = right.sequenceId, + hasLiveFlags = right.available || right.connected || right.streaming, + now = now + ) + if (leftProgress || rightProgress) { + lastDongleSequenceProgressWallMs = now + } + + HandleDashboardSignal( + valid = true, + left = left, + right = right, + dongleStateLocated = obj.optBoolean("dongleStateLocated", false), + observationBufferLocated = obj.optBoolean("observationBufferLocated", false), + dongle = dongle, + leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs), + rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs), + dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs) + ) + } catch (t: Throwable) { + Log.w(TAG, "updateHandleSignals exception: ${t.message}") + HandleDashboardSignal( + valid = false, + dongle = dongle, + leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs), + rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs), + dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs) + ) + } + } + + private fun parseHandleSignal(side: String, obj: JSONObject?): HandleSignal { + if (obj == null) return HandleSignal(side) + return HandleSignal( + side = side, + available = obj.optBoolean("available", false), + connected = obj.optBoolean("connected", false), + streaming = obj.optBoolean("streaming", false), + sequenceId = obj.optLong("sequenceId", 0L), + dataAgeMs = obj.optLong("dataAgeMs", -1L), + queueSize = obj.optInt("queueSize", 0), + health = obj.optString("health", ""), + source = obj.optString("source", "") + ) + } + + + + private fun parseDongleSignal(bound: Boolean, json: String): DongleSignal { + if (!bound || isUnavailable(json)) { + return DongleSignal(valid = false, source = "unavailable") + } + return try { + val obj = JSONObject(json) + val transport = obj.optJSONObject("transport") ?: obj + val state = firstNonBlank( + transport.optString("state", ""), + obj.optString("state", ""), + obj.optString("transportState", "") + ) + val usbPresent = boolAny( + transport.optBoolean("usbPresent", false), + transport.optBoolean("devicePresent", false), + transport.optBoolean("attached", false), + obj.optBoolean("usbPresent", false), + obj.optBoolean("devicePresent", false), + obj.optBoolean("attached", false) + ) + val usbBridgeRegistered = boolAny( + transport.optBoolean("usbBridgeRegistered", false), + transport.optBoolean("bridgeRegistered", false), + obj.optBoolean("usbBridgeRegistered", false), + obj.optBoolean("bridgeRegistered", false) + ) + val readLoopRunning = boolAny( + transport.optBoolean("readLoopRunning", false), + transport.optBoolean("bulkInRunning", false), + transport.optBoolean("running", false), + obj.optBoolean("readLoopRunning", false) + ) + val activeFlags = boolAny( + obj.optBoolean("active", false), + obj.optBoolean("available", false), + obj.optBoolean("connected", false), + transport.optBoolean("active", false), + transport.optBoolean("available", false), + transport.optBoolean("connected", false), + transport.optBoolean("usbConnected", false), + transport.optBoolean("opened", false), + transport.optBoolean("open", false), + transport.optBoolean("isOpen", false) + ) + val stateActive = state.equals("CONNECTED", ignoreCase = true) || + state.equals("RUNNING", ignoreCase = true) || + state.equals("OPEN", ignoreCase = true) || + state.equals("OPENED", ignoreCase = true) || + state.equals("ACTIVE", ignoreCase = true) || + state.equals("READY", ignoreCase = true) + + val active = usbPresent && usbBridgeRegistered && readLoopRunning && (activeFlags || stateActive) + DongleSignal( + valid = true, + active = active, + usbPresent = usbPresent, + usbBridgeRegistered = usbBridgeRegistered, + readLoopRunning = readLoopRunning, + state = state, + source = obj.optString("source", "getDongleStateJson"), + evidence = "usbPresent=$usbPresent bridge=$usbBridgeRegistered readLoop=$readLoopRunning activeFlags=$activeFlags state=$state" + ) + } catch (t: Throwable) { + Log.w(TAG, "parseDongleSignal exception: ${t.message}") + DongleSignal(valid = false, source = "parse-exception", evidence = t.message ?: "unknown") + } + } + + private fun boolAny(vararg values: Boolean?): Boolean = values.any { it == true } + + private fun firstNonBlank(vararg values: String?): String { + return values.firstOrNull { !it.isNullOrBlank() } ?: "" + } + + private fun updateSequenceProgress(side: String, sequenceId: Long, hasLiveFlags: Boolean, now: Long): Boolean { + if (!hasLiveFlags || sequenceId <= 0L) return false + return when (side) { + "left" -> { + val changed = sequenceId != lastLeftSequenceId + if (changed) { + lastLeftSequenceId = sequenceId + lastLeftSequenceProgressWallMs = now + } + changed + } + "right" -> { + val changed = sequenceId != lastRightSequenceId + if (changed) { + lastRightSequenceId = sequenceId + lastRightSequenceProgressWallMs = now + } + changed + } + else -> false + } + } + + private fun ageSince(now: Long, last: Long): Long { + return if (last <= 0L) Long.MAX_VALUE else now - last + } + + private fun logDashboardSummary( + bound: Boolean, + states: Map, + handles: HandleDashboardSignal, + runtimeStats: FloatArray, + cameraJson: String + ) { + val statsActive = runtimeStats.any { !it.isNaN() && !it.isInfinite() && it > 0.1f } + val camera = runCatching { JSONObject(cameraJson) }.getOrNull() + val cameraRunning = camera?.optBoolean("running", false) == true || camera?.optString("state", "") == "RUNNING" + val imxOpen = camera?.optBoolean("imx415OpenSucceeded", false) == true || + camera?.optJSONObject("realInternalCameraPipeline")?.optBoolean("imx415OpenSucceeded", false) == true + + Log.i( + TAG, + "Phase4B-v6 dashboard availability; bound=$bound; " + + "camera=${states[ComponentType.CAMERA]}; right=${states[ComponentType.RIGHT_HANDLE]}; " + + "dongle=${states[ComponentType.DONGLE]}; telemetry=${states[ComponentType.TELEMETRY_SAFETY]}; motor=${states[ComponentType.MOTOR]}; " + + "right.available=${handles.right.available}; right.connected=${handles.right.connected}; right.streaming=${handles.right.streaming}; " + + "right.seq=${handles.right.sequenceId}; right.dataAgeMs=${handles.right.dataAgeMs}; right.progressAgeMs=${printAge(handles.rightProgressAgeMs)}; " + + "dongleStateLocated=${handles.dongleStateLocated}; observationBufferLocated=${handles.observationBufferLocated}; dongle.progressAgeMs=${printAge(handles.dongleProgressAgeMs)}; " + + "dongle.usbActive=${handles.dongle.active}; dongle.usbPresent=${handles.dongle.usbPresent}; dongle.bridge=${handles.dongle.usbBridgeRegistered}; dongle.readLoop=${handles.dongle.readLoopRunning}; dongle.transportState=${handles.dongle.state}; " + + "statsActive=$statsActive; cameraRunning=$cameraRunning; imx415OpenSucceeded=$imxOpen; rule=$DONGLE_RULE" + ) + } + + private fun printAge(age: Long): String { + return if (age == Long.MAX_VALUE) "NA" else age.toString() + } + private fun logStateChanges( old: Map, new: Map @@ -64,168 +320,138 @@ class ComponentRegistry( result = "${oldState.name} -> ${newState.name}", isStateChange = true )) + Log.i(TAG, "Phase4B-v6 availability_change; component=${type.name}; ${oldState.name}->${newState.name}") } } } - private fun checkAvailability(type: ComponentType): ComponentState { - dumpDebugOnce() + private fun checkAvailability( + type: ComponentType, + bound: Boolean, + handleJson: String, + runtimeStats: FloatArray, + cameraJson: String, + handles: HandleDashboardSignal + ): ComponentState { return when (type.category) { ComponentCategory.ALWAYS_READY -> ComponentState.ACTIVE - ComponentCategory.RUNTIME_HOST -> checkRuntimeHostComponent(type) - ComponentCategory.PERIPHERAL -> checkPeripheral(type) + ComponentCategory.RUNTIME_HOST -> checkRuntimeHostComponent(type, bound, runtimeStats, cameraJson) + ComponentCategory.PERIPHERAL -> checkPeripheral(type, bound, handleJson, handles) } } - private fun checkRuntimeHostComponent(type: ComponentType): ComponentState { - // RuntimeHostStatus 始终可交互(Bind 按钮需在未绑定时可用) + private fun checkRuntimeHostComponent( + type: ComponentType, + bound: Boolean, + runtimeStats: FloatArray, + cameraJson: String + ): ComponentState { if (type == ComponentType.RUNTIME_HOST_STATUS) return ComponentState.ACTIVE - if (!repo.isBound()) return ComponentState.GREY + if (!bound) return ComponentState.GREY return when (type) { - ComponentType.CAMERA -> checkCamera() + ComponentType.CAMERA -> checkCamera(runtimeStats, cameraJson) ComponentType.TELEMETRY_SAFETY -> ComponentState.ACTIVE else -> ComponentState.ACTIVE } } - private fun checkPeripheral(type: ComponentType): ComponentState { - if (!repo.isBound()) return ComponentState.GREY + private fun checkPeripheral( + type: ComponentType, + bound: Boolean, + handleJson: String, + handles: HandleDashboardSignal + ): ComponentState { + if (!bound) return ComponentState.GREY return when (type) { - ComponentType.LEFT_HANDLE -> checkHandle("left") - ComponentType.RIGHT_HANDLE -> checkHandle("right") - ComponentType.BALANCE_BOARD -> checkBalanceBoard() - ComponentType.DONGLE -> checkDongle() + ComponentType.LEFT_HANDLE -> checkHandle(handles.left, handles.leftProgressAgeMs) + ComponentType.RIGHT_HANDLE -> checkHandle(handles.right, handles.rightProgressAgeMs) + ComponentType.BALANCE_BOARD -> ComponentState.GREY // CoP not integrated yet. + ComponentType.DONGLE -> checkDongle(handles) ComponentType.MOTOR -> checkMotor() else -> ComponentState.GREY } } - private var debugDumped = false - - 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( - "isBound" to repo.isBound().toString(), - "getDongleStateJson(first400)" to runCatching { repo.getDongleStateJson().take(400) }.getOrDefault("EXCEPTION") - ) - for ((name, value) in apis) { - logger.log(LogEntry( - timestamp = System.currentTimeMillis(), - component = null, - action = "DEBUG_DUMP", - params = name, - result = value, - isStateChange = false - )) - } - } - - private fun checkCamera(): ComponentState { + private fun checkCamera(runtimeStats: FloatArray, cameraJson: String): ComponentState { + // Home card means pipeline alive, not necessarily human detected. + val statsActive = runtimeStats.any { !it.isNaN() && !it.isInfinite() && it > 0.1f } + if (statsActive) return ComponentState.ACTIVE + if (isUnavailable(cameraJson)) return ComponentState.GREY return try { - val json = repo.getCameraStateJson() - if (isUnavailable(json)) ComponentState.GREY else ComponentState.ACTIVE - } catch (_: Exception) { - ComponentState.GREY - } - } - - private fun checkHandle(side: String): ComponentState { - return try { - val json = repo.getHandleStateJson() - val obj = JSONObject(json) - val sideObj = obj.optJSONObject(side) ?: return ComponentState.GREY - // handle-state.v1: check available AND connected - val available = sideObj.optBoolean("available", false) - val connected = sideObj.optBoolean("connected", false) - if (available || connected) ComponentState.ACTIVE else ComponentState.GREY - } catch (_: Exception) { - ComponentState.GREY - } - } - - private fun checkBalanceBoard(): ComponentState { - // BalanceBoard 没有专用 API,暂时用 debugSnapshot 判断 - // TODO: 确认 RuntimeHost debug snapshot 中 balance board 状态字段 - return try { - val json = repo.getDebugSnapshotJson() - if (isUnavailable(json)) return ComponentState.GREY - val obj = JSONObject(json) - val bb = obj.optJSONObject("balanceBoard") ?: obj.optJSONObject("balance_board") - if (bb == null) ComponentState.GREY - else if (bb.optBoolean("available", false) || bb.optBoolean("connected", false)) ComponentState.ACTIVE - else ComponentState.GREY - } catch (_: Exception) { - ComponentState.GREY - } - } - - private fun checkDongle(): ComponentState { - val state = computeDongleState() - latestDongleCardStateForMotorGate = state - return state - } - - private fun computeDongleState(): ComponentState { - return try { - val json = repo.getDongleStateJson() - if (isUnavailable(json)) return ComponentState.GREY - 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 - ) { + val obj = JSONObject(cameraJson) + val pipeline = obj.optJSONObject("realInternalCameraPipeline") + val pose2D = obj.optJSONObject("pose2DState") + val pose3D = obj.optJSONObject("pose3DState") + val running = obj.optBoolean("running", false) || obj.optString("state", "") == "RUNNING" + val imx415Open = obj.optBoolean("imx415OpenSucceeded", false) || pipeline?.optBoolean("imx415OpenSucceeded", false) == true + val rga = obj.optBoolean("rgaEnabled", false) || pipeline?.optBoolean("rgaEnabled", false) == true + val rknn = obj.optBoolean("rknnYoloEnabled", false) || pipeline?.optBoolean("rknnYoloEnabled", false) == true + val videoPose3D = obj.optBoolean("videoPose3DEnabled", false) || pipeline?.optBoolean("videoPose3DEnabled", false) == true + val pose2DAvailable = pose2D?.optBoolean("available", false) == true + val pose3DAvailable = pose3D?.optBoolean("available", false) == true + if (running || imx415Open || (rga && rknn) || videoPose3D || pose2DAvailable || pose3DAvailable) { ComponentState.ACTIVE } else { ComponentState.GREY } - } catch (_: Exception) { + } catch (t: Throwable) { + Log.w(TAG, "checkCamera exception: ${t.message}") ComponentState.GREY } } + private fun checkHandle(signal: HandleSignal, progressAgeMs: Long): ComponentState { + if (signal.side.isBlank()) return ComponentState.GREY + val liveFlags = signal.available || signal.connected || signal.streaming + val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS + val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS + val hasPayload = signal.sequenceId > 0L && signal.queueSize > 0 + val active = liveFlags && dataFresh && sequenceProgressFresh && hasPayload + Log.i( + TAG, + "Phase4B-v6 checkHandle; side=${signal.side}; available=${signal.available}; connected=${signal.connected}; streaming=${signal.streaming}; " + + "sequenceId=${signal.sequenceId}; queueSize=${signal.queueSize}; dataAgeMs=${signal.dataAgeMs}; progressAgeMs=${printAge(progressAgeMs)}; " + + "dataFresh=$dataFresh; sequenceProgressFresh=$sequenceProgressFresh; decision=${if (active) "ACTIVE" else "GREY"}; rule=REQUIRE_FRESH_DATA_AND_SEQUENCE_PROGRESS" + ) + return if (active) ComponentState.ACTIVE else ComponentState.GREY + } + + private fun checkDongle(handles: HandleDashboardSignal): ComponentState { + val rightFresh = isHandleFreshForDongle(handles.right, handles.rightProgressAgeMs) + val leftFresh = isHandleFreshForDongle(handles.left, handles.leftProgressAgeMs) + val dongleProgressFresh = handles.dongleProgressAgeMs <= DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS + val recentHandleProgress = dongleProgressFresh && (rightFresh || leftFresh) + + // Dongle card means SmartBase USB/CDC transport is alive. It must not require + // Right Handle IMU streaming; handles can be disconnected until a button wakes them. + // Right Handle freshness is kept only as a fallback and disconnect invalidator. + val transportActive = handles.dongle.valid && handles.dongle.active + val active = handles.valid && (transportActive || recentHandleProgress) + val state = if (active) ComponentState.ACTIVE else ComponentState.GREY + latestDongleCardStateForMotorGate = state + Log.i( + TAG, + "Phase4B-v6 checkDongle; valid=${handles.valid}; dongleStateLocated=${handles.dongleStateLocated}; observationBufferLocated=${handles.observationBufferLocated}; " + + "transportActive=$transportActive; usbPresent=${handles.dongle.usbPresent}; bridge=${handles.dongle.usbBridgeRegistered}; readLoop=${handles.dongle.readLoopRunning}; transportState=${handles.dongle.state}; " + + "rightFresh=$rightFresh; leftFresh=$leftFresh; recentHandleProgress=$recentHandleProgress; dongleProgressAgeMs=${printAge(handles.dongleProgressAgeMs)}; decision=${state.name}; " + + "rule=$DONGLE_RULE; evidence=${handles.dongle.evidence}" + ) + return state + } + + private fun isHandleFreshForDongle(signal: HandleSignal, progressAgeMs: Long): Boolean { + val liveFlags = signal.available || signal.connected || signal.streaming + val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS + val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS + return liveFlags && dataFresh && sequenceProgressFresh && signal.sequenceId > 0L + } + 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 - - val obj = JSONObject(json) - 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 - } + // Keep strict for now: Motor is not considered green until telemetry path is verified. + if (latestDongleCardStateForMotorGate != ComponentState.ACTIVE) return ComponentState.GREY + return ComponentState.GREY } - /** - * 通用不可用判断:含 error 字段、空 JSON、或 NOT_BOUND - */ private fun isUnavailable(json: String): Boolean { if (json.isBlank() || json == "{}") return true if (json.contains("\"error\"")) return true 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 c892bff..6ba1305 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateSource.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateSource.kt @@ -14,8 +14,10 @@ class AarProxyStateSource(private val bridge: BridgeRepository) : RuntimeStateSo ComponentType.CAMERA -> bridge.getCameraStateJson() ComponentType.LEFT_HANDLE, ComponentType.RIGHT_HANDLE -> bridge.getHandleStateJson() ComponentType.MOTOR -> bridge.getLatestMotorStateJson() - ComponentType.BALANCE_BOARD -> bridge.getDebugSnapshotJson() - ComponentType.DONGLE -> bridge.getDongleStateJson() + ComponentType.BALANCE_BOARD -> "{\"contractVersion\":\"kiwii.sdk-panel.balance-placeholder.v1\",\"available\":false,\"reason\":\"Balance Board detail state is not wired yet\"}" + // Phase4B-v11: Dongle detail state must include transport + slot0..3 + handle-state inference. + // Do not use raw getDongleStateJson() directly for the detail State panel because slot cache can be empty/stale. + ComponentType.DONGLE -> bridge.getDongleDetailStateJson() ComponentType.TELEMETRY_SAFETY -> bridge.getTelemetryStateJson() ComponentType.SESSION_LOG -> "{}" } diff --git a/app/src/main/java/com/kiwii/controlpanel/data/StatePoller.kt b/app/src/main/java/com/kiwii/controlpanel/data/StatePoller.kt index 2aee635..c6a611a 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/StatePoller.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/StatePoller.kt @@ -26,6 +26,13 @@ class StatePoller( private val pollIntervalMs = 1000L private var wasBound = false + @Volatile + private var focusedComponent: ComponentType = ComponentType.RUNTIME_HOST_STATUS + + fun setFocusedComponent(type: ComponentType) { + focusedComponent = type + } + fun startPolling() { scope.launch { while (isActive) { @@ -41,7 +48,7 @@ class StatePoller( isStateChange = true )) } - val snapshot = pollAllStates() + val snapshot = pollFocusedStates() _states.value = snapshot } else { if (wasBound) { @@ -62,19 +69,17 @@ class StatePoller( } } - private fun pollAllStates(): Map { + private fun pollFocusedStates(): Map { val now = System.currentTimeMillis() - val polledTypes = listOf( - ComponentType.RUNTIME_HOST_STATUS, - ComponentType.CAMERA, - ComponentType.LEFT_HANDLE, - ComponentType.RIGHT_HANDLE, - ComponentType.BALANCE_BOARD, - ComponentType.DONGLE, - ComponentType.MOTOR, - ComponentType.TELEMETRY_SAFETY - ) - return polledTypes.mapNotNull { type -> + // Phase4B-v11: detail pages must not poll every component. The previous all-component + // loop called heavy/global APIs such as getDebugSnapshotJson and could starve/hang the + // current page after back/re-enter. Poll RuntimeHost + the currently visible component only. + val types = linkedSetOf(ComponentType.RUNTIME_HOST_STATUS) + if (focusedComponent != ComponentType.RUNTIME_HOST_STATUS && focusedComponent != ComponentType.SESSION_LOG) { + types += focusedComponent + } + + return types.mapNotNull { type -> try { type to StateSnapshot( data = runtimeStateRepo.getComponentState(type), @@ -82,7 +87,6 @@ class StatePoller( isDebugChannel = runtimeStateRepo.isUsingDebugChannel() ) } catch (e: Exception) { - // 异常必记 logger.log(LogEntry( timestamp = now, component = type, @@ -92,7 +96,7 @@ class StatePoller( isStateChange = false )) type to StateSnapshot( - data = "{\"error\":\"${e.message}\"}", + data = "{\"error\":\"${escapeJson(e.message ?: e.javaClass.simpleName)}\"}", updatedAt = now, isDebugChannel = runtimeStateRepo.isUsingDebugChannel(), hasError = true @@ -101,6 +105,8 @@ class StatePoller( }.toMap() } + private fun escapeJson(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"") + fun stopPolling() { scope.cancel() } diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/adapter/ComponentCardAdapter.kt b/app/src/main/java/com/kiwii/controlpanel/ui/adapter/ComponentCardAdapter.kt index a370830..e641f7a 100644 --- a/app/src/main/java/com/kiwii/controlpanel/ui/adapter/ComponentCardAdapter.kt +++ b/app/src/main/java/com/kiwii/controlpanel/ui/adapter/ComponentCardAdapter.kt @@ -1,5 +1,6 @@ package com.kiwii.controlpanel.ui.adapter +import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.content.ContextCompat @@ -32,6 +33,7 @@ class ComponentCardAdapter( RecyclerView.ViewHolder(binding.root) { fun bind(type: ComponentType, state: ComponentState) { + Log.i("KiwiiSDKPanelUI", "Phase4B-v6 bind home card; component=${type.name}; state=${state.name}") binding.tvComponentName.text = type.displayName binding.tvStatusSummary.text = when (state) { ComponentState.ACTIVE -> "Available" diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/CameraOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/CameraOps.kt index 58ed159..13b4fdb 100644 --- a/app/src/main/java/com/kiwii/controlpanel/ui/components/CameraOps.kt +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/CameraOps.kt @@ -16,25 +16,31 @@ class CameraOps( override fun createView(): View { val layout = verticalLayout() - // 预览占位 layout.addView(TextView(context).apply { - text = "Preview: Pending RuntimeHost debug channel" + text = "Phase 4A Camera Observation\n2D/3D are split into LatestFastState + ObservationBuffer. 3D uses VideoPose3D 27F. Timestamps use hostArrivalNs / hostEstimatedSampleTimeNs." textSize = 12f setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle)) setPadding(0, 0, 0, 16) }) - layout.addView(createSection("SDK Operations")) + layout.addView(createSection("Camera Observation Display")) + layout.addView(createButton("getCameraObservationStateJson") { + viewModel.executeOperation(ComponentType.CAMERA, "getCameraObservationStateJson") { + viewModel.bridgeRepo.getCameraObservationStateJson() + } + }) layout.addView(createButton("getCameraStateJson") { viewModel.executeOperation(ComponentType.CAMERA, "getCameraStateJson") { viewModel.bridgeRepo.getCameraStateJson() } }) - layout.addView(createButton("getPerceptionStateJson") { - viewModel.executeOperation(ComponentType.CAMERA, "getPerceptionStateJson") { - viewModel.bridgeRepo.getPerceptionStateJson() + layout.addView(createButton("getHealthStateJson") { + viewModel.executeOperation(ComponentType.CAMERA, "getHealthStateJson") { + viewModel.bridgeRepo.getHealthStateJson() } }) + + layout.addView(createSection("Pose Payloads")) layout.addView(createButton("getLatestPose2D") { viewModel.executeOperation(ComponentType.CAMERA, "getLatestPose2D") { viewModel.bridgeRepo.getLatestPose2D() @@ -50,6 +56,13 @@ class CameraOps( viewModel.bridgeRepo.getLatestRuntimeStats() } }) + + layout.addView(createSection("Raw Runtime Queries")) + layout.addView(createButton("getPerceptionStateJson") { + viewModel.executeOperation(ComponentType.CAMERA, "getPerceptionStateJson") { + viewModel.bridgeRepo.getPerceptionStateJson() + } + }) layout.addView(createButton("getLatestPoseFrameJson") { viewModel.executeOperation(ComponentType.CAMERA, "getLatestPoseFrameJson") { viewModel.bridgeRepo.getLatestPoseFrameJson() 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 7af475d..5982324 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 @@ -13,30 +13,12 @@ class DongleOps( override fun createView(): View { val layout = verticalLayout() - 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() + // Phase4B-v11: one action only. It reads the SDK Panel detail-state JSON and updates + // both State panel and Session Log. It does not mutate RuntimeHost state. + layout.addView(createSection("Dongle Diagnostics")) + layout.addView(createButton("readDongleState") { + viewModel.executeOperation(ComponentType.DONGLE, "readDongleState") { + viewModel.bridgeRepo.getDongleDetailStateJson() } }) diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/SessionLogOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/SessionLogOps.kt index b628458..301975c 100644 --- a/app/src/main/java/com/kiwii/controlpanel/ui/components/SessionLogOps.kt +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/SessionLogOps.kt @@ -2,6 +2,9 @@ package com.kiwii.controlpanel.ui.components import android.content.Context import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.util.Log import android.view.View import android.widget.TextView import android.widget.Toast @@ -16,6 +19,7 @@ class SessionLogOps(context: Context) : BaseOps(context) { override fun createView(): View { val layout = verticalLayout() + val handler = Handler(Looper.getMainLooper()) val logView = TextView(context).apply { textSize = 11f @@ -30,10 +34,30 @@ class SessionLogOps(context: Context) : BaseOps(context) { logView.text = entries.joinToString("\n") { entry -> val time = sdf.format(Date(entry.timestamp)) val comp = entry.component?.displayName ?: "GLOBAL" - "$time [$comp] ${entry.action}: ${entry.result?.take(60) ?: ""}" + val params = entry.params?.let { " [$it]" } ?: "" + "$time [$comp] ${entry.action}$params: ${entry.result?.take(120) ?: ""}" }.ifEmpty { "No logs" } + Log.i(TAG_SESSION, "Phase4B-v7 session log refreshed; entries=${entries.size}") } + val refreshRunnable = object : Runnable { + override fun run() { + refreshLog() + handler.postDelayed(this, 1000L) + } + } + + layout.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + handler.removeCallbacks(refreshRunnable) + handler.post(refreshRunnable) + } + + override fun onViewDetachedFromWindow(v: View) { + handler.removeCallbacks(refreshRunnable) + } + }) + layout.addView(createButton("Refresh") { refreshLog() }) layout.addView(createButton("Export") { val uri = SessionLogger.export(context) @@ -58,4 +82,8 @@ class SessionLogOps(context: Context) : BaseOps(context) { return layout } + + companion object { + private const val TAG_SESSION = "KiwiiSDKPanelSession" + } } diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailViewModel.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailViewModel.kt index 537505e..24a5a41 100644 --- a/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailViewModel.kt +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailViewModel.kt @@ -1,5 +1,6 @@ package com.kiwii.controlpanel.ui.detail +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kiwii.controlpanel.data.AarProxyStateSource @@ -11,6 +12,7 @@ import com.kiwii.controlpanel.logging.SessionLogger import com.kiwii.controlpanel.model.ComponentType import com.kiwii.controlpanel.model.OperationResult import com.kiwii.controlpanel.model.StateSnapshot +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -19,21 +21,38 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +private const val TAG_OPS = "KiwiiSDKPanelOps" class ComponentDetailViewModel : ViewModel() { val bridgeRepo = BridgeRepository() private val runtimeStateRepo = RuntimeStateRepository(AarProxyStateSource(bridgeRepo)) private val statePoller = StatePoller(runtimeStateRepo, bridgeRepo, SessionLogger) + private val operationExecutor = Executors.newCachedThreadPool() private val _operationResult = MutableStateFlow?>(null) val operationResult: StateFlow?> = _operationResult private val _componentType = MutableStateFlow(ComponentType.RUNTIME_HOST_STATUS) + private val _manualStateSnapshot = MutableStateFlow?>(null) - val stateSnapshot: StateFlow = _componentType - .combine(statePoller.states) { type, map -> map[type] } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + val stateSnapshot: StateFlow = combine( + _componentType, + statePoller.states, + _manualStateSnapshot + ) { type, map, manual -> + val polled = map[type] + val manualSnapshot = if (manual?.first == type) manual.second else null + when { + manualSnapshot != null && (polled == null || manualSnapshot.updatedAt >= polled.updatedAt) -> manualSnapshot + else -> polled + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) private val _logEntries = MutableStateFlow>(emptyList()) val logEntries: StateFlow> = _logEntries @@ -42,7 +61,7 @@ class ComponentDetailViewModel : ViewModel() { statePoller.startPolling() viewModelScope.launch { while (isActive) { - _logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value) + refreshVisibleLogs() delay(1000) } } @@ -50,6 +69,8 @@ class ComponentDetailViewModel : ViewModel() { fun setComponentType(type: ComponentType) { _componentType.value = type + statePoller.setFocusedComponent(type) + refreshVisibleLogs() } fun executeOperation( @@ -59,9 +80,21 @@ class ComponentDetailViewModel : ViewModel() { block: () -> T ) { viewModelScope.launch { - val result = bridgeRepo.callAsync(block) + val startedAt = System.currentTimeMillis() + Log.i(TAG_OPS, "Phase4B-v11 control click started; component=$componentType; action=$actionName; params=${params ?: ""}") + SessionLogger.log(LogEntry( + timestamp = startedAt, + component = componentType, + action = "TX:$actionName", + params = params, + result = "STARTED" + )) + refreshVisibleLogs() + + val result = callBlockingWithTimeout(actionName, block) _operationResult.value = result + val now = System.currentTimeMillis() val resultStr = when (result) { is OperationResult.Success<*> -> result.data.toString() is OperationResult.Error -> "ERROR: ${result.exception.message}" @@ -69,20 +102,69 @@ class ComponentDetailViewModel : ViewModel() { is OperationResult.NoData -> "NO_DATA" } + if (componentType == _componentType.value && result is OperationResult.Success<*> && result.data is String) { + val text = result.data.trim() + if (text.startsWith("{")) { + _manualStateSnapshot.value = componentType to StateSnapshot( + data = result.data, + updatedAt = now, + isDebugChannel = false + ) + } + } + + val elapsedMs = now - startedAt + Log.i(TAG_OPS, "Phase4B-v11 control click completed; component=$componentType; action=$actionName; elapsedMs=$elapsedMs; resultKind=${result.javaClass.simpleName}") SessionLogger.log(LogEntry( - timestamp = System.currentTimeMillis(), + timestamp = now, component = componentType, - action = actionName, + action = "RX:$actionName", params = params, result = resultStr )) - - _logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value) + refreshVisibleLogs() } } + private suspend fun callBlockingWithTimeout( + actionName: String, + block: () -> T + ): OperationResult = withContext(Dispatchers.IO) { + val future = operationExecutor.submit> { + try { + val result = block() + if (result == null || (result is String && result.isBlank())) { + @Suppress("UNCHECKED_CAST") + OperationResult.NoData as OperationResult + } else { + OperationResult.Success(result) + } + } catch (e: Exception) { + OperationResult.Error(e) + } + } + + try { + future.get(OPERATION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + future.cancel(true) + OperationResult.Error(Exception("$actionName timed out after ${OPERATION_TIMEOUT_MS}ms")) + } catch (e: Exception) { + OperationResult.Error(Exception("$actionName failed: ${e.message}", e)) + } + } + + private fun refreshVisibleLogs() { + _logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value) + } + override fun onCleared() { super.onCleared() statePoller.stopPolling() + operationExecutor.shutdownNow() + } + + private companion object { + const val OPERATION_TIMEOUT_MS = 2000L } } diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogColorizer.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogColorizer.kt index b8699f9..ced0d55 100644 --- a/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogColorizer.kt +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogColorizer.kt @@ -10,6 +10,13 @@ object LogColorizer { fun classify(entry: LogEntry): EntryType { if (entry.isStateChange) return EntryType.SYS val action = entry.action.lowercase() + + // Phase4B-v10: explicit TX/RX action prefixes must win over result-based inference. + // Otherwise a visible click marker such as action="TX:readDongleState" with + // result="STARTED" is incorrectly rendered as [RX] TX:readDongleState => STARTED. + if (action.startsWith("tx:")) return EntryType.TX + if (action.startsWith("rx:")) return EntryType.RX + if (action.contains("poll") || action.contains("stream") || action == "snapshot" || action == "connection") { return EntryType.SYS } diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusParser.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusParser.kt index f7ce495..216d999 100644 --- a/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusParser.kt +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusParser.kt @@ -1,5 +1,6 @@ package com.kiwii.controlpanel.ui.detail +import org.json.JSONArray import org.json.JSONObject /** @@ -14,28 +15,36 @@ data class StatItem( val detail: String = "" ) -/** 从 RuntimeState JSON 中动态提取所有顶层字段为 StatItem 列表 */ +/** 从 RuntimeState JSON 中动态提取字段为 StatItem 列表 */ object RuntimeHostStatusParser { fun parse(json: String, isBound: Boolean): List { - val items = mutableListOf() - val obj = try { JSONObject(json) } catch (_: Exception) { - return items + return emptyList() } + if (looksLikeDongleState(obj)) { + return parseDongleState(obj) + } + + val items = mutableListOf() val keys = obj.keys() while (keys.hasNext()) { val key = keys.next() val raw = obj.opt(key) ?: continue - val item = when { - raw is JSONObject -> StatItem( + val item = when (raw) { + is JSONObject -> StatItem( label = humanize(key), value = raw.optString("state", raw.optString("value", "…")).compactValue(), detail = raw.optString("detail", "") ) + is JSONArray -> StatItem( + label = humanize(key), + value = "${raw.length()} items", + detail = raw.toString().compactDetail() + ) else -> StatItem( label = humanize(key), value = raw.toString().compactValue() @@ -47,6 +56,186 @@ object RuntimeHostStatusParser { return items } + private fun looksLikeDongleState(obj: JSONObject): Boolean { + val contract = obj.optString("contractVersion", "").lowercase() + if (contract.contains("dongle")) return true + if (obj.has("dongle") && obj.has("handleState")) return true + if (obj.has("transport") && (obj.has("slots") || obj.has("slotStatus") || obj.has("slotStates"))) return true + if (obj.has("transport") && obj.optJSONObject("transport")?.has("usbPresent") == true) return true + return false + } + + /** Phase4B-v11.5: Dongle State = operational state first; raw dongle query may be disabled on safe UI path. */ + private fun parseDongleState(obj: JSONObject): List { + val items = mutableListOf() + val transport = obj.optJSONObject("transport") ?: obj.optJSONObject("dongle")?.optJSONObject("transport") ?: JSONObject() + + val transportState = firstNonBlank( + transport.optString("state", ""), + obj.optString("state", ""), + if (transport.optBooleanFlexible("active") || transport.optBooleanFlexible("connected") || obj.optBooleanFlexible("active")) "ACTIVE" else "UNKNOWN" + ) + + val usbPresent = transport.optBooleanFlexible("usbPresent") + val bridge = transport.optBooleanFlexible("usbBridgeRegistered") + val readLoop = transport.optBooleanFlexible("readLoopRunning") + + items += StatItem( + label = "Transport", + value = transportState.compactValue(), + detail = if (transport.optString("rawDongleQuery", "") == "disabled-on-detail-ui-path") "safe path; raw transport skipped" else "usb=${usbPresent.renderBool("present", "missing")}; bridge=${bridge.renderBool("registered", "no")}; readLoop=${readLoop.renderBool("running", "stopped")}" + ) + items += StatItem( + label = "USB", + value = usbPresent.renderBool("PRESENT", "MISSING"), + detail = endpointDetail(transport) + ) + items += StatItem( + label = "Bridge", + value = bridge.renderBool("REGISTERED", "NO"), + detail = "RuntimeHost USB bridge" + ) + items += StatItem( + label = "Read Loop", + value = readLoop.renderBool("RUNNING", "STOPPED"), + detail = ageDetail(transport) + ) + + val slotEvidenceNote = firstNonBlank( + obj.optString("slotEvidenceNote", ""), + normalizeLegacySlotDiagnostic(obj.optString("slotDiagnostic", "")) + ) + if (slotEvidenceNote.isNotBlank()) { + items += StatItem( + label = "Slot Evidence", + value = "INFERRED", + detail = slotEvidenceNote.compactDetail(72) + ) + } + + val slots = firstSlotArray(obj) + for (slotIndex in 0..3) { + items += parseSlot(slotIndex, slots) + } + + return items + } + + private fun firstSlotArray(obj: JSONObject): JSONArray? { + obj.optJSONArray("slots")?.let { return it } + obj.optJSONArray("slotStatus")?.let { return it } + obj.optJSONArray("slotStates")?.let { return it } + obj.optJSONObject("dongle")?.optJSONArray("slots")?.let { return it } + obj.optJSONObject("status")?.optJSONArray("slots")?.let { return it } + return null + } + + private fun parseSlot(slotIndex: Int, slots: JSONArray?): StatItem { + val slot = findSlotObject(slotIndex, slots) + if (slot == null) { + return StatItem( + label = "Slot $slotIndex", + value = "UNKNOWN", + detail = "No raw slot report" + ) + } + + val connected = slot.optBooleanFlexible("connected") || slot.optBooleanFlexible("available") || slot.optBooleanFlexible("active") + val state = firstNonBlank( + slot.optString("state", ""), + slot.optString("status", ""), + if (connected) "CONNECTED" else "DISCONNECTED" + ) + val device = firstNonBlank( + slot.optString("device", ""), + slot.optString("dev", ""), + slot.optString("deviceType", ""), + slot.optString("deviceId", "") + ) + val seq = firstNonBlank(slot.optString("sequenceId", ""), slot.optString("seq", "")) + val source = slot.optString("source", "") + val rawSlotReport = slot.optString("rawSlotReport", "") + val inferred = slot.optBoolean("inferred", false) + val explicitDetail = slot.optString("detail", "") + + val detailParts = mutableListOf() + if (device.isNotBlank()) detailParts += "device=$device" + if (seq.isNotBlank() && seq != "0") detailParts += "seq=$seq" + val age = slot.optLongOrNull("dataAgeMs") ?: slot.optLongOrNull("ageMs") + if (age != null && age >= 0L) detailParts += "age=${age}ms" + if (source.isNotBlank()) detailParts += "source=$source" + if (rawSlotReport.isNotBlank()) detailParts += "rawSlotReport=$rawSlotReport" + if (inferred) detailParts += "inferred=true" + if (explicitDetail.isNotBlank()) detailParts += explicitDetail + + return StatItem( + label = "Slot $slotIndex", + value = state.compactValue(), + detail = detailParts.joinToString("; ").ifBlank { "reported" }.compactDetail(72) + ) + } + + private fun findSlotObject(slotIndex: Int, slots: JSONArray?): JSONObject? { + if (slots == null) return null + for (i in 0 until slots.length()) { + val obj = slots.optJSONObject(i) ?: continue + val reportedIndex = obj.optIntOrNull("slot") ?: obj.optIntOrNull("slotId") ?: obj.optIntOrNull("index") + if (reportedIndex == slotIndex) return obj + } + return slots.optJSONObject(slotIndex) + } + + private fun endpointDetail(transport: JSONObject): String { + val epIn = firstNonBlank(transport.optString("epIn", ""), transport.optString("endpointIn", "")) + val epOut = firstNonBlank(transport.optString("epOut", ""), transport.optString("endpointOut", "")) + return when { + epIn.isNotBlank() || epOut.isNotBlank() -> "in=$epIn out=$epOut".trim() + else -> firstNonBlank(transport.optString("device", ""), transport.optString("product", ""), "USB CDC") + }.compactDetail() + } + + private fun ageDetail(transport: JSONObject): String { + val inAge = transport.optLongOrNull("lastUsbInAgeMs") + val outAge = transport.optLongOrNull("lastUsbOutAgeMs") + val parts = mutableListOf() + if (inAge != null) parts += "inAge=${inAge}ms" + if (outAge != null) parts += "outAge=${outAge}ms" + return parts.joinToString("; ").ifBlank { "USB read loop state" } + } + + private fun firstNonBlank(vararg values: String): String = values.firstOrNull { it.isNotBlank() } ?: "" + + private fun normalizeLegacySlotDiagnostic(value: String): String { + if (value.isBlank()) return "" + if (value.contains("Right Handle is connected in handle-state", ignoreCase = true)) { + return "Slot 1 inferred from Right Handle IMU; raw slot not queried." + } + return value + } + + private fun Boolean.renderBool(yes: String, no: String): String = if (this) yes else no + + private fun JSONObject.optBooleanFlexible(key: String): Boolean { + if (!has(key)) return false + val raw = opt(key) ?: return false + return when (raw) { + is Boolean -> raw + is Number -> raw.toInt() != 0 + is String -> raw.equals("true", ignoreCase = true) || raw == "1" || raw.equals("yes", ignoreCase = true) || raw.equals("connected", ignoreCase = true) || raw.equals("active", ignoreCase = true) || raw.equals("running", ignoreCase = true) + else -> false + } + } + + private fun JSONObject.optIntOrNull(key: String): Int? { + if (!has(key)) return null + return try { optInt(key) } catch (_: Exception) { null } + } + + private fun JSONObject.optLongOrNull(key: String): Long? { + if (!has(key)) return null + return try { optLong(key) } catch (_: Exception) { null } + } + /** camelCase / snake_case → 可读标签 */ private fun humanize(key: String): String { return key @@ -59,4 +248,9 @@ object RuntimeHostStatusParser { return replace("\"", "") .let { if (it.length > 16) "${it.take(14)}..." else it } } + + private fun String.compactDetail(maxLen: Int = 48): String { + return replace("\"", "") + .let { if (it.length > maxLen) "${it.take(maxLen - 3)}..." else it } + } }