Improve SDK panel dongle state diagnostics
This commit is contained in:
@@ -5,6 +5,8 @@ import com.kiwii.bridge.KiwiiRuntimeClientBridge
|
|||||||
import com.kiwii.controlpanel.model.OperationResult
|
import com.kiwii.controlpanel.model.OperationResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
class BridgeRepository {
|
class BridgeRepository {
|
||||||
|
|
||||||
@@ -29,6 +31,72 @@ class BridgeRepository {
|
|||||||
fun requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason)
|
fun requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason)
|
||||||
|
|
||||||
fun getCameraStateJson(): String = KiwiiRuntimeClientBridge.getCameraStateJson()
|
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 getPerceptionStateJson(): String = KiwiiRuntimeClientBridge.getPerceptionStateJson()
|
||||||
fun getLatestPose2D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose2D()
|
fun getLatestPose2D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose2D()
|
||||||
fun getLatestPose3D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose3D()
|
fun getLatestPose3D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose3D()
|
||||||
@@ -100,6 +168,221 @@ class BridgeRepository {
|
|||||||
fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command)
|
fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command)
|
||||||
fun getDeviceCommandStateJson(): String = KiwiiRuntimeClientBridge.getDeviceCommandStateJson()
|
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<FloatArray> {
|
private fun readHandleImuQueueCompat(methodName: String): Array<FloatArray> {
|
||||||
return try {
|
return try {
|
||||||
val method = KiwiiRuntimeClientBridge::class.java.getMethod(methodName)
|
val method = KiwiiRuntimeClientBridge::class.java.getMethod(methodName)
|
||||||
|
|||||||
@@ -26,20 +26,72 @@ class ComponentRegistry(
|
|||||||
private var previousStates = emptyMap<ComponentType, ComponentState>()
|
private var previousStates = emptyMap<ComponentType, ComponentState>()
|
||||||
|
|
||||||
private companion object {
|
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.
|
private data class HandleSignal(
|
||||||
// Keep the latest Dongle card decision from the same detection loop so Motor cannot remain green
|
val side: String,
|
||||||
// after the Dongle transport is disconnected, while preserving v4 Dongle detection behavior.
|
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
|
@Volatile
|
||||||
private var latestDongleCardStateForMotorGate: ComponentState = ComponentState.GREY
|
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() {
|
fun startDetection() {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
while (isActive) {
|
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)
|
logStateChanges(previousStates, result)
|
||||||
previousStates = result
|
previousStates = result
|
||||||
_availability.value = 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<ComponentType, ComponentState>,
|
||||||
|
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(
|
private fun logStateChanges(
|
||||||
old: Map<ComponentType, ComponentState>,
|
old: Map<ComponentType, ComponentState>,
|
||||||
new: Map<ComponentType, ComponentState>
|
new: Map<ComponentType, ComponentState>
|
||||||
@@ -64,168 +320,138 @@ class ComponentRegistry(
|
|||||||
result = "${oldState.name} -> ${newState.name}",
|
result = "${oldState.name} -> ${newState.name}",
|
||||||
isStateChange = true
|
isStateChange = true
|
||||||
))
|
))
|
||||||
|
Log.i(TAG, "Phase4B-v6 availability_change; component=${type.name}; ${oldState.name}->${newState.name}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAvailability(type: ComponentType): ComponentState {
|
private fun checkAvailability(
|
||||||
dumpDebugOnce()
|
type: ComponentType,
|
||||||
|
bound: Boolean,
|
||||||
|
handleJson: String,
|
||||||
|
runtimeStats: FloatArray,
|
||||||
|
cameraJson: String,
|
||||||
|
handles: HandleDashboardSignal
|
||||||
|
): ComponentState {
|
||||||
return when (type.category) {
|
return when (type.category) {
|
||||||
ComponentCategory.ALWAYS_READY -> ComponentState.ACTIVE
|
ComponentCategory.ALWAYS_READY -> ComponentState.ACTIVE
|
||||||
ComponentCategory.RUNTIME_HOST -> checkRuntimeHostComponent(type)
|
ComponentCategory.RUNTIME_HOST -> checkRuntimeHostComponent(type, bound, runtimeStats, cameraJson)
|
||||||
ComponentCategory.PERIPHERAL -> checkPeripheral(type)
|
ComponentCategory.PERIPHERAL -> checkPeripheral(type, bound, handleJson, handles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkRuntimeHostComponent(type: ComponentType): ComponentState {
|
private fun checkRuntimeHostComponent(
|
||||||
// RuntimeHostStatus 始终可交互(Bind 按钮需在未绑定时可用)
|
type: ComponentType,
|
||||||
|
bound: Boolean,
|
||||||
|
runtimeStats: FloatArray,
|
||||||
|
cameraJson: String
|
||||||
|
): ComponentState {
|
||||||
if (type == ComponentType.RUNTIME_HOST_STATUS) return ComponentState.ACTIVE
|
if (type == ComponentType.RUNTIME_HOST_STATUS) return ComponentState.ACTIVE
|
||||||
if (!repo.isBound()) return ComponentState.GREY
|
if (!bound) return ComponentState.GREY
|
||||||
return when (type) {
|
return when (type) {
|
||||||
ComponentType.CAMERA -> checkCamera()
|
ComponentType.CAMERA -> checkCamera(runtimeStats, cameraJson)
|
||||||
ComponentType.TELEMETRY_SAFETY -> ComponentState.ACTIVE
|
ComponentType.TELEMETRY_SAFETY -> ComponentState.ACTIVE
|
||||||
else -> ComponentState.ACTIVE
|
else -> ComponentState.ACTIVE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkPeripheral(type: ComponentType): ComponentState {
|
private fun checkPeripheral(
|
||||||
if (!repo.isBound()) return ComponentState.GREY
|
type: ComponentType,
|
||||||
|
bound: Boolean,
|
||||||
|
handleJson: String,
|
||||||
|
handles: HandleDashboardSignal
|
||||||
|
): ComponentState {
|
||||||
|
if (!bound) return ComponentState.GREY
|
||||||
return when (type) {
|
return when (type) {
|
||||||
ComponentType.LEFT_HANDLE -> checkHandle("left")
|
ComponentType.LEFT_HANDLE -> checkHandle(handles.left, handles.leftProgressAgeMs)
|
||||||
ComponentType.RIGHT_HANDLE -> checkHandle("right")
|
ComponentType.RIGHT_HANDLE -> checkHandle(handles.right, handles.rightProgressAgeMs)
|
||||||
ComponentType.BALANCE_BOARD -> checkBalanceBoard()
|
ComponentType.BALANCE_BOARD -> ComponentState.GREY // CoP not integrated yet.
|
||||||
ComponentType.DONGLE -> checkDongle()
|
ComponentType.DONGLE -> checkDongle(handles)
|
||||||
ComponentType.MOTOR -> checkMotor()
|
ComponentType.MOTOR -> checkMotor()
|
||||||
else -> ComponentState.GREY
|
else -> ComponentState.GREY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var debugDumped = false
|
private fun checkCamera(runtimeStats: FloatArray, cameraJson: String): ComponentState {
|
||||||
|
// Home card means pipeline alive, not necessarily human detected.
|
||||||
private fun dumpDebugOnce() {
|
val statsActive = runtimeStats.any { !it.isNaN() && !it.isInfinite() && it > 0.1f }
|
||||||
if (debugDumped || !repo.isBound()) return
|
if (statsActive) return ComponentState.ACTIVE
|
||||||
debugDumped = true
|
if (isUnavailable(cameraJson)) return ComponentState.GREY
|
||||||
// 首页首次检测不要调用 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 {
|
|
||||||
return try {
|
return try {
|
||||||
val json = repo.getCameraStateJson()
|
val obj = JSONObject(cameraJson)
|
||||||
if (isUnavailable(json)) ComponentState.GREY else ComponentState.ACTIVE
|
val pipeline = obj.optJSONObject("realInternalCameraPipeline")
|
||||||
} catch (_: Exception) {
|
val pose2D = obj.optJSONObject("pose2DState")
|
||||||
ComponentState.GREY
|
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
|
||||||
private fun checkHandle(side: String): ComponentState {
|
val rknn = obj.optBoolean("rknnYoloEnabled", false) || pipeline?.optBoolean("rknnYoloEnabled", false) == true
|
||||||
return try {
|
val videoPose3D = obj.optBoolean("videoPose3DEnabled", false) || pipeline?.optBoolean("videoPose3DEnabled", false) == true
|
||||||
val json = repo.getHandleStateJson()
|
val pose2DAvailable = pose2D?.optBoolean("available", false) == true
|
||||||
val obj = JSONObject(json)
|
val pose3DAvailable = pose3D?.optBoolean("available", false) == true
|
||||||
val sideObj = obj.optJSONObject(side) ?: return ComponentState.GREY
|
if (running || imx415Open || (rga && rknn) || videoPose3D || pose2DAvailable || pose3DAvailable) {
|
||||||
// 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
|
|
||||||
) {
|
|
||||||
ComponentState.ACTIVE
|
ComponentState.ACTIVE
|
||||||
} else {
|
} else {
|
||||||
ComponentState.GREY
|
ComponentState.GREY
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "checkCamera exception: ${t.message}")
|
||||||
ComponentState.GREY
|
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 {
|
private fun checkMotor(): ComponentState {
|
||||||
return try {
|
// Keep strict for now: Motor is not considered green until telemetry path is verified.
|
||||||
// Motor is physically downstream of the SmartBase Dongle.
|
if (latestDongleCardStateForMotorGate != ComponentState.ACTIVE) return ComponentState.GREY
|
||||||
// V7 requires fresh motor telemetry; stale cached telemetry after Dongle re-plug must not turn Motor green.
|
return ComponentState.GREY
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用不可用判断:含 error 字段、空 JSON、或 NOT_BOUND
|
|
||||||
*/
|
|
||||||
private fun isUnavailable(json: String): Boolean {
|
private fun isUnavailable(json: String): Boolean {
|
||||||
if (json.isBlank() || json == "{}") return true
|
if (json.isBlank() || json == "{}") return true
|
||||||
if (json.contains("\"error\"")) return true
|
if (json.contains("\"error\"")) return true
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ class AarProxyStateSource(private val bridge: BridgeRepository) : RuntimeStateSo
|
|||||||
ComponentType.CAMERA -> bridge.getCameraStateJson()
|
ComponentType.CAMERA -> bridge.getCameraStateJson()
|
||||||
ComponentType.LEFT_HANDLE, ComponentType.RIGHT_HANDLE -> bridge.getHandleStateJson()
|
ComponentType.LEFT_HANDLE, ComponentType.RIGHT_HANDLE -> bridge.getHandleStateJson()
|
||||||
ComponentType.MOTOR -> bridge.getLatestMotorStateJson()
|
ComponentType.MOTOR -> bridge.getLatestMotorStateJson()
|
||||||
ComponentType.BALANCE_BOARD -> bridge.getDebugSnapshotJson()
|
ComponentType.BALANCE_BOARD -> "{\"contractVersion\":\"kiwii.sdk-panel.balance-placeholder.v1\",\"available\":false,\"reason\":\"Balance Board detail state is not wired yet\"}"
|
||||||
ComponentType.DONGLE -> bridge.getDongleStateJson()
|
// 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.TELEMETRY_SAFETY -> bridge.getTelemetryStateJson()
|
||||||
ComponentType.SESSION_LOG -> "{}"
|
ComponentType.SESSION_LOG -> "{}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ class StatePoller(
|
|||||||
private val pollIntervalMs = 1000L
|
private val pollIntervalMs = 1000L
|
||||||
private var wasBound = false
|
private var wasBound = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var focusedComponent: ComponentType = ComponentType.RUNTIME_HOST_STATUS
|
||||||
|
|
||||||
|
fun setFocusedComponent(type: ComponentType) {
|
||||||
|
focusedComponent = type
|
||||||
|
}
|
||||||
|
|
||||||
fun startPolling() {
|
fun startPolling() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
@@ -41,7 +48,7 @@ class StatePoller(
|
|||||||
isStateChange = true
|
isStateChange = true
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
val snapshot = pollAllStates()
|
val snapshot = pollFocusedStates()
|
||||||
_states.value = snapshot
|
_states.value = snapshot
|
||||||
} else {
|
} else {
|
||||||
if (wasBound) {
|
if (wasBound) {
|
||||||
@@ -62,19 +69,17 @@ class StatePoller(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pollAllStates(): Map<ComponentType, StateSnapshot> {
|
private fun pollFocusedStates(): Map<ComponentType, StateSnapshot> {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val polledTypes = listOf(
|
// Phase4B-v11: detail pages must not poll every component. The previous all-component
|
||||||
ComponentType.RUNTIME_HOST_STATUS,
|
// loop called heavy/global APIs such as getDebugSnapshotJson and could starve/hang the
|
||||||
ComponentType.CAMERA,
|
// current page after back/re-enter. Poll RuntimeHost + the currently visible component only.
|
||||||
ComponentType.LEFT_HANDLE,
|
val types = linkedSetOf(ComponentType.RUNTIME_HOST_STATUS)
|
||||||
ComponentType.RIGHT_HANDLE,
|
if (focusedComponent != ComponentType.RUNTIME_HOST_STATUS && focusedComponent != ComponentType.SESSION_LOG) {
|
||||||
ComponentType.BALANCE_BOARD,
|
types += focusedComponent
|
||||||
ComponentType.DONGLE,
|
}
|
||||||
ComponentType.MOTOR,
|
|
||||||
ComponentType.TELEMETRY_SAFETY
|
return types.mapNotNull { type ->
|
||||||
)
|
|
||||||
return polledTypes.mapNotNull { type ->
|
|
||||||
try {
|
try {
|
||||||
type to StateSnapshot(
|
type to StateSnapshot(
|
||||||
data = runtimeStateRepo.getComponentState(type),
|
data = runtimeStateRepo.getComponentState(type),
|
||||||
@@ -82,7 +87,6 @@ class StatePoller(
|
|||||||
isDebugChannel = runtimeStateRepo.isUsingDebugChannel()
|
isDebugChannel = runtimeStateRepo.isUsingDebugChannel()
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// 异常必记
|
|
||||||
logger.log(LogEntry(
|
logger.log(LogEntry(
|
||||||
timestamp = now,
|
timestamp = now,
|
||||||
component = type,
|
component = type,
|
||||||
@@ -92,7 +96,7 @@ class StatePoller(
|
|||||||
isStateChange = false
|
isStateChange = false
|
||||||
))
|
))
|
||||||
type to StateSnapshot(
|
type to StateSnapshot(
|
||||||
data = "{\"error\":\"${e.message}\"}",
|
data = "{\"error\":\"${escapeJson(e.message ?: e.javaClass.simpleName)}\"}",
|
||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
isDebugChannel = runtimeStateRepo.isUsingDebugChannel(),
|
isDebugChannel = runtimeStateRepo.isUsingDebugChannel(),
|
||||||
hasError = true
|
hasError = true
|
||||||
@@ -101,6 +105,8 @@ class StatePoller(
|
|||||||
}.toMap()
|
}.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun escapeJson(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
|
||||||
fun stopPolling() {
|
fun stopPolling() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.kiwii.controlpanel.ui.adapter
|
package com.kiwii.controlpanel.ui.adapter
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -32,6 +33,7 @@ class ComponentCardAdapter(
|
|||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun bind(type: ComponentType, state: ComponentState) {
|
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.tvComponentName.text = type.displayName
|
||||||
binding.tvStatusSummary.text = when (state) {
|
binding.tvStatusSummary.text = when (state) {
|
||||||
ComponentState.ACTIVE -> "Available"
|
ComponentState.ACTIVE -> "Available"
|
||||||
|
|||||||
@@ -16,25 +16,31 @@ class CameraOps(
|
|||||||
override fun createView(): View {
|
override fun createView(): View {
|
||||||
val layout = verticalLayout()
|
val layout = verticalLayout()
|
||||||
|
|
||||||
// 预览占位
|
|
||||||
layout.addView(TextView(context).apply {
|
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
|
textSize = 12f
|
||||||
setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle))
|
setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle))
|
||||||
setPadding(0, 0, 0, 16)
|
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") {
|
layout.addView(createButton("getCameraStateJson") {
|
||||||
viewModel.executeOperation(ComponentType.CAMERA, "getCameraStateJson") {
|
viewModel.executeOperation(ComponentType.CAMERA, "getCameraStateJson") {
|
||||||
viewModel.bridgeRepo.getCameraStateJson()
|
viewModel.bridgeRepo.getCameraStateJson()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
layout.addView(createButton("getPerceptionStateJson") {
|
layout.addView(createButton("getHealthStateJson") {
|
||||||
viewModel.executeOperation(ComponentType.CAMERA, "getPerceptionStateJson") {
|
viewModel.executeOperation(ComponentType.CAMERA, "getHealthStateJson") {
|
||||||
viewModel.bridgeRepo.getPerceptionStateJson()
|
viewModel.bridgeRepo.getHealthStateJson()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
layout.addView(createSection("Pose Payloads"))
|
||||||
layout.addView(createButton("getLatestPose2D") {
|
layout.addView(createButton("getLatestPose2D") {
|
||||||
viewModel.executeOperation(ComponentType.CAMERA, "getLatestPose2D") {
|
viewModel.executeOperation(ComponentType.CAMERA, "getLatestPose2D") {
|
||||||
viewModel.bridgeRepo.getLatestPose2D()
|
viewModel.bridgeRepo.getLatestPose2D()
|
||||||
@@ -50,6 +56,13 @@ class CameraOps(
|
|||||||
viewModel.bridgeRepo.getLatestRuntimeStats()
|
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") {
|
layout.addView(createButton("getLatestPoseFrameJson") {
|
||||||
viewModel.executeOperation(ComponentType.CAMERA, "getLatestPoseFrameJson") {
|
viewModel.executeOperation(ComponentType.CAMERA, "getLatestPoseFrameJson") {
|
||||||
viewModel.bridgeRepo.getLatestPoseFrameJson()
|
viewModel.bridgeRepo.getLatestPoseFrameJson()
|
||||||
|
|||||||
@@ -13,30 +13,12 @@ class DongleOps(
|
|||||||
override fun createView(): View {
|
override fun createView(): View {
|
||||||
val layout = verticalLayout()
|
val layout = verticalLayout()
|
||||||
|
|
||||||
layout.addView(createSection("Dongle Transport"))
|
// Phase4B-v11: one action only. It reads the SDK Panel detail-state JSON and updates
|
||||||
layout.addView(createButton("getDongleStateJson") {
|
// both State panel and Session Log. It does not mutate RuntimeHost state.
|
||||||
viewModel.executeOperation(ComponentType.DONGLE, "getDongleStateJson") {
|
layout.addView(createSection("Dongle Diagnostics"))
|
||||||
viewModel.bridgeRepo.getDongleStateJson()
|
layout.addView(createButton("readDongleState") {
|
||||||
}
|
viewModel.executeOperation(ComponentType.DONGLE, "readDongleState") {
|
||||||
})
|
viewModel.bridgeRepo.getDongleDetailStateJson()
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.kiwii.controlpanel.ui.components
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -16,6 +19,7 @@ class SessionLogOps(context: Context) : BaseOps(context) {
|
|||||||
|
|
||||||
override fun createView(): View {
|
override fun createView(): View {
|
||||||
val layout = verticalLayout()
|
val layout = verticalLayout()
|
||||||
|
val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
val logView = TextView(context).apply {
|
val logView = TextView(context).apply {
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
@@ -30,10 +34,30 @@ class SessionLogOps(context: Context) : BaseOps(context) {
|
|||||||
logView.text = entries.joinToString("\n") { entry ->
|
logView.text = entries.joinToString("\n") { entry ->
|
||||||
val time = sdf.format(Date(entry.timestamp))
|
val time = sdf.format(Date(entry.timestamp))
|
||||||
val comp = entry.component?.displayName ?: "GLOBAL"
|
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" }
|
}.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("Refresh") { refreshLog() })
|
||||||
layout.addView(createButton("Export") {
|
layout.addView(createButton("Export") {
|
||||||
val uri = SessionLogger.export(context)
|
val uri = SessionLogger.export(context)
|
||||||
@@ -58,4 +82,8 @@ class SessionLogOps(context: Context) : BaseOps(context) {
|
|||||||
|
|
||||||
return layout
|
return layout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG_SESSION = "KiwiiSDKPanelSession"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.kiwii.controlpanel.ui.detail
|
package com.kiwii.controlpanel.ui.detail
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.kiwii.controlpanel.data.AarProxyStateSource
|
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.ComponentType
|
||||||
import com.kiwii.controlpanel.model.OperationResult
|
import com.kiwii.controlpanel.model.OperationResult
|
||||||
import com.kiwii.controlpanel.model.StateSnapshot
|
import com.kiwii.controlpanel.model.StateSnapshot
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -19,21 +21,38 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
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() {
|
class ComponentDetailViewModel : ViewModel() {
|
||||||
|
|
||||||
val bridgeRepo = BridgeRepository()
|
val bridgeRepo = BridgeRepository()
|
||||||
private val runtimeStateRepo = RuntimeStateRepository(AarProxyStateSource(bridgeRepo))
|
private val runtimeStateRepo = RuntimeStateRepository(AarProxyStateSource(bridgeRepo))
|
||||||
private val statePoller = StatePoller(runtimeStateRepo, bridgeRepo, SessionLogger)
|
private val statePoller = StatePoller(runtimeStateRepo, bridgeRepo, SessionLogger)
|
||||||
|
private val operationExecutor = Executors.newCachedThreadPool()
|
||||||
|
|
||||||
private val _operationResult = MutableStateFlow<OperationResult<*>?>(null)
|
private val _operationResult = MutableStateFlow<OperationResult<*>?>(null)
|
||||||
val operationResult: StateFlow<OperationResult<*>?> = _operationResult
|
val operationResult: StateFlow<OperationResult<*>?> = _operationResult
|
||||||
|
|
||||||
private val _componentType = MutableStateFlow(ComponentType.RUNTIME_HOST_STATUS)
|
private val _componentType = MutableStateFlow(ComponentType.RUNTIME_HOST_STATUS)
|
||||||
|
private val _manualStateSnapshot = MutableStateFlow<Pair<ComponentType, StateSnapshot>?>(null)
|
||||||
|
|
||||||
val stateSnapshot: StateFlow<StateSnapshot?> = _componentType
|
val stateSnapshot: StateFlow<StateSnapshot?> = combine(
|
||||||
.combine(statePoller.states) { type, map -> map[type] }
|
_componentType,
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
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<List<LogEntry>>(emptyList())
|
private val _logEntries = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||||
val logEntries: StateFlow<List<LogEntry>> = _logEntries
|
val logEntries: StateFlow<List<LogEntry>> = _logEntries
|
||||||
@@ -42,7 +61,7 @@ class ComponentDetailViewModel : ViewModel() {
|
|||||||
statePoller.startPolling()
|
statePoller.startPolling()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
_logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value)
|
refreshVisibleLogs()
|
||||||
delay(1000)
|
delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +69,8 @@ class ComponentDetailViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun setComponentType(type: ComponentType) {
|
fun setComponentType(type: ComponentType) {
|
||||||
_componentType.value = type
|
_componentType.value = type
|
||||||
|
statePoller.setFocusedComponent(type)
|
||||||
|
refreshVisibleLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> executeOperation(
|
fun <T> executeOperation(
|
||||||
@@ -59,9 +80,21 @@ class ComponentDetailViewModel : ViewModel() {
|
|||||||
block: () -> T
|
block: () -> T
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
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
|
_operationResult.value = result
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
val resultStr = when (result) {
|
val resultStr = when (result) {
|
||||||
is OperationResult.Success<*> -> result.data.toString()
|
is OperationResult.Success<*> -> result.data.toString()
|
||||||
is OperationResult.Error -> "ERROR: ${result.exception.message}"
|
is OperationResult.Error -> "ERROR: ${result.exception.message}"
|
||||||
@@ -69,20 +102,69 @@ class ComponentDetailViewModel : ViewModel() {
|
|||||||
is OperationResult.NoData -> "NO_DATA"
|
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(
|
SessionLogger.log(LogEntry(
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = now,
|
||||||
component = componentType,
|
component = componentType,
|
||||||
action = actionName,
|
action = "RX:$actionName",
|
||||||
params = params,
|
params = params,
|
||||||
result = resultStr
|
result = resultStr
|
||||||
))
|
))
|
||||||
|
refreshVisibleLogs()
|
||||||
_logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> callBlockingWithTimeout(
|
||||||
|
actionName: String,
|
||||||
|
block: () -> T
|
||||||
|
): OperationResult<T> = withContext(Dispatchers.IO) {
|
||||||
|
val future = operationExecutor.submit<OperationResult<T>> {
|
||||||
|
try {
|
||||||
|
val result = block()
|
||||||
|
if (result == null || (result is String && result.isBlank())) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
OperationResult.NoData as OperationResult<T>
|
||||||
|
} 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() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
statePoller.stopPolling()
|
statePoller.stopPolling()
|
||||||
|
operationExecutor.shutdownNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val OPERATION_TIMEOUT_MS = 2000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ object LogColorizer {
|
|||||||
fun classify(entry: LogEntry): EntryType {
|
fun classify(entry: LogEntry): EntryType {
|
||||||
if (entry.isStateChange) return EntryType.SYS
|
if (entry.isStateChange) return EntryType.SYS
|
||||||
val action = entry.action.lowercase()
|
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") {
|
if (action.contains("poll") || action.contains("stream") || action == "snapshot" || action == "connection") {
|
||||||
return EntryType.SYS
|
return EntryType.SYS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.kiwii.controlpanel.ui.detail
|
package com.kiwii.controlpanel.ui.detail
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,28 +15,36 @@ data class StatItem(
|
|||||||
val detail: String = ""
|
val detail: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 从 RuntimeState JSON 中动态提取所有顶层字段为 StatItem 列表 */
|
/** 从 RuntimeState JSON 中动态提取字段为 StatItem 列表 */
|
||||||
object RuntimeHostStatusParser {
|
object RuntimeHostStatusParser {
|
||||||
|
|
||||||
fun parse(json: String, isBound: Boolean): List<StatItem> {
|
fun parse(json: String, isBound: Boolean): List<StatItem> {
|
||||||
val items = mutableListOf<StatItem>()
|
|
||||||
|
|
||||||
val obj = try {
|
val obj = try {
|
||||||
JSONObject(json)
|
JSONObject(json)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
return items
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (looksLikeDongleState(obj)) {
|
||||||
|
return parseDongleState(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
val items = mutableListOf<StatItem>()
|
||||||
val keys = obj.keys()
|
val keys = obj.keys()
|
||||||
while (keys.hasNext()) {
|
while (keys.hasNext()) {
|
||||||
val key = keys.next()
|
val key = keys.next()
|
||||||
val raw = obj.opt(key) ?: continue
|
val raw = obj.opt(key) ?: continue
|
||||||
val item = when {
|
val item = when (raw) {
|
||||||
raw is JSONObject -> StatItem(
|
is JSONObject -> StatItem(
|
||||||
label = humanize(key),
|
label = humanize(key),
|
||||||
value = raw.optString("state", raw.optString("value", "…")).compactValue(),
|
value = raw.optString("state", raw.optString("value", "…")).compactValue(),
|
||||||
detail = raw.optString("detail", "")
|
detail = raw.optString("detail", "")
|
||||||
)
|
)
|
||||||
|
is JSONArray -> StatItem(
|
||||||
|
label = humanize(key),
|
||||||
|
value = "${raw.length()} items",
|
||||||
|
detail = raw.toString().compactDetail()
|
||||||
|
)
|
||||||
else -> StatItem(
|
else -> StatItem(
|
||||||
label = humanize(key),
|
label = humanize(key),
|
||||||
value = raw.toString().compactValue()
|
value = raw.toString().compactValue()
|
||||||
@@ -47,6 +56,186 @@ object RuntimeHostStatusParser {
|
|||||||
return items
|
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<StatItem> {
|
||||||
|
val items = mutableListOf<StatItem>()
|
||||||
|
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<String>()
|
||||||
|
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<String>()
|
||||||
|
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 → 可读标签 */
|
/** camelCase / snake_case → 可读标签 */
|
||||||
private fun humanize(key: String): String {
|
private fun humanize(key: String): String {
|
||||||
return key
|
return key
|
||||||
@@ -59,4 +248,9 @@ object RuntimeHostStatusParser {
|
|||||||
return replace("\"", "")
|
return replace("\"", "")
|
||||||
.let { if (it.length > 16) "${it.take(14)}..." else it }
|
.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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user