2 Commits

17 changed files with 771 additions and 2093 deletions
@@ -2,54 +2,12 @@ package com.kiwii.controlpanel.data
import android.app.Activity import android.app.Activity
import com.kiwii.bridge.KiwiiRuntimeClientBridge import com.kiwii.bridge.KiwiiRuntimeClientBridge
import com.kiwii.controlpanel.ui.components.MotorTelemetrySnapshotCache
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
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class BridgeRepository { class BridgeRepository {
private companion object {
const val HANDLE_CACHE_MAX_AGE_MS = 5000L
const val HANDLE_REFRESH_MIN_INTERVAL_MS = 1000L
const val HANDLE_REFRESH_STUCK_RESET_MS = 5000L
const val DONGLE_CACHE_MAX_AGE_MS = 3000L
const val DONGLE_REFRESH_MIN_INTERVAL_MS = 1000L
const val DONGLE_REFRESH_STUCK_RESET_MS = 5000L
@Volatile
private var cachedHandleStateJson: String = ""
@Volatile
private var cachedHandleStateAtMs: Long = 0L
@Volatile
private var lastHandleRefreshAttemptMs: Long = 0L
private val handleRefreshInFlight = AtomicBoolean(false)
private val handleRefreshExecutor = Executors.newCachedThreadPool { runnable ->
Thread(runnable, "SDKPanelHandleStateRefresh").apply { isDaemon = true }
}
@Volatile
private var cachedDongleStateJson: String = ""
@Volatile
private var cachedDongleStateAtMs: Long = 0L
@Volatile
private var lastDongleRefreshAttemptMs: Long = 0L
private val dongleRefreshInFlight = AtomicBoolean(false)
private val dongleRefreshExecutor = Executors.newCachedThreadPool { runnable ->
Thread(runnable, "SDKPanelDongleStateRefresh").apply { isDaemon = true }
}
}
fun bind(activity: Activity): Boolean = KiwiiRuntimeClientBridge.bind(activity) fun bind(activity: Activity): Boolean = KiwiiRuntimeClientBridge.bind(activity)
fun unbind() = KiwiiRuntimeClientBridge.unbind() fun unbind() = KiwiiRuntimeClientBridge.unbind()
fun isBound(): Boolean = KiwiiRuntimeClientBridge.isBound() fun isBound(): Boolean = KiwiiRuntimeClientBridge.isBound()
@@ -71,72 +29,6 @@ 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()
@@ -170,244 +62,7 @@ class BridgeRepository {
fun submitRightHeStream(streamId: Int, payload: String): String = fun submitRightHeStream(streamId: Int, payload: String): String =
KiwiiRuntimeClientBridge.submitRightHeStream(streamId, buildHeStreamPatternJson(streamId, payload)) KiwiiRuntimeClientBridge.submitRightHeStream(streamId, buildHeStreamPatternJson(streamId, payload))
fun getHandleStateJson(): String { fun getHandleStateJson(): String = KiwiiRuntimeClientBridge.getHandleStateJson()
val json = KiwiiRuntimeClientBridge.getHandleStateJson()
cacheHandleStateIfValid(json)
return json
}
/**
* Phase4B-v11.9: best-effort live HandleState refresh without blocking UI/poller callers.
*
* Important: some RuntimeHost debug/binder paths can block after long idle. A stuck refresh
* must not permanently disable future refresh attempts, so the in-flight flag is allowed to
* expire. The cached/last-known snapshot is never cleared on failure.
*/
fun requestHandleStateRefreshAsync(reason: String = "") {
if (!isBound()) return
val now = System.currentTimeMillis()
val stuck = handleRefreshInFlight.get() &&
lastHandleRefreshAttemptMs > 0L &&
now - lastHandleRefreshAttemptMs > HANDLE_REFRESH_STUCK_RESET_MS
if (stuck) {
handleRefreshInFlight.set(false)
}
if (now - lastHandleRefreshAttemptMs < HANDLE_REFRESH_MIN_INTERVAL_MS) return
if (!handleRefreshInFlight.compareAndSet(false, true)) return
lastHandleRefreshAttemptMs = now
handleRefreshExecutor.execute {
try {
val json = KiwiiRuntimeClientBridge.getHandleStateJson()
cacheHandleStateIfValid(json)
} catch (_: Throwable) {
// Keep last-known snapshot. Never clear cached handle state on refresh failure.
} finally {
handleRefreshInFlight.set(false)
}
}
}
fun getCachedRawHandleStateJsonOrUnavailable(): String {
val now = System.currentTimeMillis()
val cacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
return if (cachedHandleStateJson.isNotBlank()) {
val obj = parseJsonOrEmpty(cachedHandleStateJson)
obj.put("uiPath", "SDK_PANEL_CACHED_HANDLE_STATE")
obj.put("cacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs)
obj.put("cacheFresh", cacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS)
obj.toString()
} else {
JSONObject()
.put("error", "no-cached-handle-state")
.put("source", "SDK_Panel.cached-handle-state.v11.18")
.put("cacheAgeMs", -1L)
.put("cacheFresh", false)
.toString()
}
}
/**
* Phase4B-v11.17: best-effort live Dongle transport refresh without blocking
* home-card rendering. This reads RuntimeHost's fast transport-only dongle state;
* the cached result is then used by both the home card and readDongleState detail.
*/
fun requestDongleStateRefreshAsync(reason: String = "") {
if (!isBound()) return
val now = System.currentTimeMillis()
val stuck = dongleRefreshInFlight.get() &&
lastDongleRefreshAttemptMs > 0L &&
now - lastDongleRefreshAttemptMs > DONGLE_REFRESH_STUCK_RESET_MS
if (stuck) {
dongleRefreshInFlight.set(false)
}
if (now - lastDongleRefreshAttemptMs < DONGLE_REFRESH_MIN_INTERVAL_MS) return
if (!dongleRefreshInFlight.compareAndSet(false, true)) return
lastDongleRefreshAttemptMs = now
dongleRefreshExecutor.execute {
try {
val json = KiwiiRuntimeClientBridge.getDongleStateJson()
cacheDongleStateIfValid(json)
} catch (_: Throwable) {
// Keep last-known dongle state. Never clear cached state on refresh failure.
} finally {
dongleRefreshInFlight.set(false)
}
}
}
fun getCachedRawDongleStateJsonOrUnavailable(): String {
val now = System.currentTimeMillis()
val cacheAgeMs = if (cachedDongleStateAtMs > 0L) now - cachedDongleStateAtMs else Long.MAX_VALUE
return if (cachedDongleStateJson.isNotBlank()) {
val obj = parseJsonOrEmpty(cachedDongleStateJson)
obj.put("uiPath", "SDK_PANEL_CACHED_DONGLE_STATE")
obj.put("cacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs)
obj.put("cacheFresh", cacheAgeMs in 0..DONGLE_CACHE_MAX_AGE_MS)
obj.toString()
} else {
JSONObject()
.put("contractVersion", "kiwii.sdk-panel.cached-dongle-state.v11.17")
.put("error", "no-cached-dongle-state")
.put("source", "SDK_Panel.cached-dongle-state")
.put("cacheAgeMs", -1L)
.put("cacheFresh", false)
.toString()
}
}
private fun cacheDongleStateIfValid(json: String) {
if (json.isNotBlank() && !json.contains("\"error\"")) {
cachedDongleStateJson = json
cachedDongleStateAtMs = System.currentTimeMillis()
}
}
private fun cacheHandleStateIfValid(json: String) {
if (json.isNotBlank() && !json.contains("\"error\"")) {
cachedHandleStateJson = json
cachedHandleStateAtMs = System.currentTimeMillis()
}
}
/**
* Phase4B-v11.9: last-known, no-blocking Handle detail data path.
*
* Left/Right Handle controls must preserve the three diagnostics users rely on:
* 1) IMU latest, 2) IMU queue / ObservationBuffer summary, 3) overall HandleState.
* However, the raw AAR methods for latest/queue/state can block after long idle periods.
* These safe helpers therefore read only the last-known HandleState cached by
* asynchronous refresh and return immediately. They never perform live binder calls.
*/
fun getCachedHandleStateJsonForUi(side: String = ""): String {
val now = System.currentTimeMillis()
val cacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
val hasLastKnown = cachedHandleStateJson.isNotBlank()
val fresh = hasLastKnown && cacheAgeMs <= HANDLE_CACHE_MAX_AGE_MS
val handle = if (hasLastKnown) parseJsonOrEmpty(cachedHandleStateJson) else JSONObject()
val sideKey = normalizeHandleSide(side)
val sideObj = if (sideKey.isNotBlank()) handle.optJSONObject(sideKey) ?: JSONObject() else JSONObject()
return JSONObject()
.put("contractVersion", "kiwii.sdk-panel.handle-last-known-state.v11.9")
.put("source", "SDK_Panel.last-known-handle-state")
.put("noLiveBinderCall", true)
.put("cacheFresh", fresh)
.put("cacheStale", hasLastKnown && !fresh)
.put("cacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs)
.put("side", sideKey.ifBlank { "both" })
.put("sideState", sideObj)
.put("handleState", if (hasLastKnown) handle else JSONObject()
.put("error", "no-cached-handle-state")
.put("detail", "No last-known HandleState yet. Live binder query is intentionally skipped on UI button/state path."))
.toString()
}
fun getCachedHandleIMULatestText(side: String): String {
val snapshot = cachedHandleSnapshot(side)
if (!snapshot.hasLastKnown) {
return "NO_CACHED_HANDLE_STATE\n" +
"side=${snapshot.side}\n" +
"detail=No last-known HandleState yet; live binder query skipped."
}
val h = snapshot.sideJson
val freshness = if (snapshot.cacheFresh) "FRESH" else "STALE"
val q = formatJsonArray(h.optJSONArray("quaternion"))
val acc = formatJsonArray(h.optJSONArray("accelerationG"))
return "IMU Latest (last-known HandleState / $freshness)\n" +
"side=${snapshot.side}\n" +
"available=${h.optBooleanFlexible("available")} connected=${h.optBooleanFlexible("connected")} streaming=${h.optBooleanFlexible("streaming")}\n" +
"seq=${h.optLong("sequenceId", h.optLong("seq", 0L))} dataAgeMs=${h.optLong("dataAgeMs", -1L)} cacheAgeMs=${snapshot.cacheAgeMs}\n" +
"latestArrayLength=${h.optInt("latestArrayLength", 0)} source=${h.optString("source", "")}\n" +
"q=$q\n" +
"accG=$acc"
}
fun getCachedHandleIMUQueueText(side: String): String {
val snapshot = cachedHandleSnapshot(side)
if (!snapshot.hasLastKnown) {
return "NO_CACHED_HANDLE_STATE\n" +
"side=${snapshot.side}\n" +
"detail=No last-known ObservationBuffer summary yet; live queue query skipped."
}
val h = snapshot.sideJson
val freshness = if (snapshot.cacheFresh) "FRESH" else "STALE"
val seq = h.optLong("sequenceId", h.optLong("seq", 0L))
val q = formatJsonArray(h.optJSONArray("quaternion"))
val acc = formatJsonArray(h.optJSONArray("accelerationG"))
return "IMU Queue / ObservationBuffer Summary (last-known / $freshness)\n" +
"side=${snapshot.side}\n" +
"queueSize=${h.optInt("queueSize", 0)} latestArrayLength=${h.optInt("latestArrayLength", 0)}\n" +
"seqLatest=$seq dataAgeMs=${h.optLong("dataAgeMs", -1L)} cacheAgeMs=${snapshot.cacheAgeMs}\n" +
"observationBuffer=summary-only; raw frame dump not queried on this UI path\n" +
"latest.q=$q\n" +
"latest.accG=$acc"
}
private data class CachedHandleSnapshot(
val side: String,
val hasLastKnown: Boolean,
val cacheFresh: Boolean,
val cacheAgeMs: Long,
val handleJson: JSONObject,
val sideJson: JSONObject
)
private fun cachedHandleSnapshot(side: String): CachedHandleSnapshot {
val now = System.currentTimeMillis()
val cacheAge = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
val hasLastKnown = cachedHandleStateJson.isNotBlank()
val fresh = hasLastKnown && cacheAge <= HANDLE_CACHE_MAX_AGE_MS
val handle = if (hasLastKnown) parseJsonOrEmpty(cachedHandleStateJson) else JSONObject()
val sideKey = normalizeHandleSide(side)
val sideObj = handle.optJSONObject(sideKey) ?: JSONObject()
return CachedHandleSnapshot(
side = sideKey,
hasLastKnown = hasLastKnown,
cacheFresh = fresh,
cacheAgeMs = if (cacheAge == Long.MAX_VALUE) -1L else cacheAge,
handleJson = handle,
sideJson = sideObj
)
}
private fun normalizeHandleSide(side: String): String {
val s = side.trim().lowercase()
return when {
s.startsWith("l") -> "left"
s.startsWith("r") -> "right"
else -> s
}
}
private fun formatJsonArray(arr: JSONArray?): String {
if (arr == null || arr.length() == 0) return "[]"
val out = ArrayList<String>()
for (i in 0 until arr.length()) {
out.add(String.format(java.util.Locale.US, "%.4f", arr.optDouble(i, 0.0)))
}
return out.joinToString(prefix = "[", postfix = "]")
}
fun setMotorWeightKg(weight: Float): com.kiwii.bridge.MotorCommandResult = KiwiiRuntimeClientBridge.setMotorWeightKg(weight) fun setMotorWeightKg(weight: Float): com.kiwii.bridge.MotorCommandResult = KiwiiRuntimeClientBridge.setMotorWeightKg(weight)
fun setMotorTrainingMode(mode: Int, param: Float): String = KiwiiRuntimeClientBridge.setMotorTrainingMode(mode, param) fun setMotorTrainingMode(mode: Int, param: Float): String = KiwiiRuntimeClientBridge.setMotorTrainingMode(mode, param)
@@ -445,248 +100,6 @@ 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.9 SDK Panel no-blocking 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 {
// Phase4B-v11.17: readDongleState remains zero-blocking, but no longer uses a
// fake NOT_QUERIED placeholder as the primary state. The home-card poller keeps a
// cached RuntimeHost dongle transport snapshot; this method displays that cached
// raw state plus inferred slot evidence from cached Handle/Motor snapshots.
requestDongleStateRefreshAsync("read-dongle-state-click")
val now = System.currentTimeMillis()
val handleCacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
val handleRaw = if (cachedHandleStateJson.isNotBlank()) {
cachedHandleStateJson
} else {
JSONObject()
.put("error", "no-cached-handle-state")
.put("handleCacheAgeMs", if (handleCacheAgeMs == Long.MAX_VALUE) -1L else handleCacheAgeMs)
.put("detail", "Safe Dongle detail path uses last-known HandleState only; live binder query skipped to avoid idle timeout.")
.toString()
}
return buildDongleDetailStateJson(
getCachedRawDongleStateJsonOrUnavailable(),
handleRaw,
if (handleCacheAgeMs == Long.MAX_VALUE) -1L else handleCacheAgeMs
)
}
fun getDongleSafePlaceholderStateJson(): String = buildSafeDonglePlaceholderJson()
private fun buildSafeDonglePlaceholderJson(): String {
return JSONObject()
.put("contractVersion", "kiwii.sdk-panel.dongle-safe-placeholder.v11.9")
.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", "Live raw transport query skipped on no-blocking detail path."))
.toString()
}
private fun buildDongleDetailStateJson(dongleRaw: String, handleRaw: String, handleCacheAgeMs: Long): String {
val dongle = parseJsonOrEmpty(dongleRaw)
val handle = parseJsonOrEmpty(handleRaw)
val out = JSONObject()
out.put("contractVersion", "kiwii.sdk-panel.dongle-detail-state.v11.17")
out.put("source", "SDK_Panel.cached-dongle-and-handle-state-detail")
out.put("diagnosticSemantics", "transport-state-first; no-live-binder-call-on-dongle-detail; inferred-slot-report-secondary")
out.put("handleCacheAgeMs", handleCacheAgeMs)
out.put("handleCacheFresh", handleCacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS)
out.put("handleCacheStale", handleCacheAgeMs > HANDLE_CACHE_MAX_AGE_MS)
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, handleCacheAgeMs))
val evidenceNote = buildSlotEvidenceNote(dongle, handle, handleCacheAgeMs)
if (evidenceNote.isNotBlank()) out.put("slotEvidenceNote", evidenceNote)
return out.toString()
}
private fun buildSdkPanelSlots(dongle: JSONObject, handle: JSONObject, handleCacheAgeMs: Long): JSONArray {
val sourceSlots = firstSlotArray(dongle)
val slots = JSONArray()
val left = handle.optJSONObject("left") ?: JSONObject()
val right = handle.optJSONObject("right") ?: JSONObject()
val motorSlotEvidence = MotorTelemetrySnapshotCache.dongleSlotEvidenceJson()
for (slotIndex in 0..3) {
val reported = findSlotObject(slotIndex, sourceSlots)
val slot = JSONObject()
slot.put("slot", slotIndex)
val inferredHandle = when (slotIndex) {
0 -> if (hasFreshHandleSlotEvidence(left, handleCacheAgeMs)) "LEFT_HANDLE" else ""
1 -> if (hasFreshHandleSlotEvidence(right, handleCacheAgeMs)) "RIGHT_HANDLE" else ""
else -> ""
}
if (slotIndex == 3 && motorSlotEvidence != null) {
val keys = motorSlotEvidence.keys()
while (keys.hasNext()) {
val key = keys.next()
slot.put(key, motorSlotEvidence.opt(key))
}
} 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-fresh-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))
slot.put("stale", handleCacheAgeMs > HANDLE_CACHE_MAX_AGE_MS)
slot.put("evidenceAgeMs", handleCacheAgeMs)
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 fresh handle-state; rawSlot=$rawSlotReport")
} else if (reported == null) {
slot.put("detail", "Connected via fresh handle-state; rawSlot=$rawSlotReport")
} else {
slot.put("detail", "Connected via fresh 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 skipped" else "No raw slot report")
}
slots.put(slot)
}
return slots
}
private fun buildSlotEvidenceNote(dongle: JSONObject, handle: JSONObject, handleCacheAgeMs: Long): String {
val sourceSlots = firstSlotArray(dongle)
val right = handle.optJSONObject("right") ?: JSONObject()
val rightFresh = hasFreshHandleSlotEvidence(right, handleCacheAgeMs)
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
val notes = mutableListOf<String>()
if (rightFresh && !reportedSlot1Connected) notes += "Slot 1 inferred from cached Right Handle data."
if (MotorTelemetrySnapshotCache.dongleSlotEvidenceJson() != null) notes += "Slot 3 inferred from explicit Motor runtime state."
return notes.joinToString(" ")
}
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 hasFreshHandleSlotEvidence(handle: JSONObject, handleCacheAgeMs: Long): 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 dataAgeMs = handle.optLong("dataAgeMs", Long.MAX_VALUE)
val cacheFresh = handleCacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS
val dataFresh = dataAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS
return cacheFresh && dataFresh && (available || connected || streaming) && 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)
@@ -6,7 +6,6 @@ import com.kiwii.controlpanel.logging.SessionLogger
import com.kiwii.controlpanel.model.ComponentCategory import com.kiwii.controlpanel.model.ComponentCategory
import com.kiwii.controlpanel.model.ComponentState import com.kiwii.controlpanel.model.ComponentState
import com.kiwii.controlpanel.model.ComponentType import com.kiwii.controlpanel.model.ComponentType
import com.kiwii.controlpanel.ui.components.MotorTelemetrySnapshotCache
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -27,106 +26,20 @@ class ComponentRegistry(
private var previousStates = emptyMap<ComponentType, ComponentState>() private var previousStates = emptyMap<ComponentType, ComponentState>()
private companion object { private companion object {
const val TAG = "KiwiiSDKPanelState" const val MOTOR_TELEMETRY_FRESH_TIMEOUT_MS = 5000L
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_TRANSPORT_FRESH_TIMEOUT_MS = 3_000L
const val MOTOR_STATUS_FRESH_TIMEOUT_MS = 5_000L
const val SLOT_STATUS_FRESH_TIMEOUT_MS = 5_000L
const val DONGLE_RULE = "REQUIRE_FRESH_SMARTBASE_USB_TRANSPORT"
} }
private data class SlotSignal( // Motor is physically downstream of the SmartBase Dongle.
val slot: Int = -1, // Keep the latest Dongle card decision from the same detection loop so Motor cannot remain green
val dev: Int = 0, // after the Dongle transport is disconnected, while preserving v4 Dongle detection behavior.
val name: String = "",
val state: String = "",
val connected: Boolean = false,
val lastSeenAgeMs: Long = Long.MAX_VALUE,
val source: String = ""
) {
val freshConnected: Boolean
get() = connected && lastSeenAgeMs in 0..5_000L
}
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 = "",
val slot: SlotSignal = SlotSignal()
)
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 stale: Boolean = true,
val dataStale: Boolean = true,
val lastUsbInAgeMs: Long = Long.MAX_VALUE,
val cacheAgeMs: Long = Long.MAX_VALUE,
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 motorSlot: SlotSignal = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER"),
val leftProgressAgeMs: Long = Long.MAX_VALUE,
val rightProgressAgeMs: Long = Long.MAX_VALUE,
val dongleProgressAgeMs: Long = Long.MAX_VALUE,
val handleCacheAgeMs: Long = Long.MAX_VALUE
)
@Volatile @Volatile
private var latestDongleCardStateForMotorGate: ComponentState = ComponentState.GREY private var latestDongleCardStateForMotorGate: ComponentState = ComponentState.GREY
@Volatile
private var latestHandleSignalsForMotorSlot: SlotSignal? = null
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 bound = repo.isBound() val result = ComponentType.entries.associateWith { checkAvailability(it) }
if (bound) { // 检测状态变化,记录日志
repo.requestHandleStateRefreshAsync("availability-poll")
repo.requestDongleStateRefreshAsync("availability-poll")
MotorTelemetrySnapshotCache.requestRuntimeStateRefreshAsync(repo, "availability-poll")
}
val handleJson = if (bound) repo.getCachedRawHandleStateJsonOrUnavailable() else "{\"error\":\"NOT_BOUND\"}"
// Phase4B-v11.9: home-card availability must be cold-start safe and never block on
// RuntimeHost debug/binder reads. Camera/Dongle/Telemetry home cards use binder-bound
// runtime availability; detailed state buttons remain no-blocking/last-known.
val runtimeStats = FloatArray(0)
val cameraJson = if (bound) "{\"contractVersion\":\"kiwii.sdk-panel.camera-bound-placeholder.v11.9\",\"state\":\"BOUND_RUNTIME\",\"running\":true}" else "{\"error\":\"NOT_BOUND\"}"
val dongleJson = if (bound) repo.getCachedRawDongleStateJsonOrUnavailable() 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
@@ -135,292 +48,6 @@ 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)) {
latestHandleSignalsForMotorSlot = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER", source = "handle-unavailable")
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 handleCacheAgeMs = obj.optLong("cacheAgeMs", Long.MAX_VALUE)
val slots = obj.optJSONArray("dongleSlots")
val leftSlot = parseSlotSignal(slots, 0, 0x11, "LEFT_HANDLE")
val rightSlot = parseSlotSignal(slots, 1, 0x12, "RIGHT_HANDLE")
val motorSlot = parseSlotSignal(slots, 3, 0x31, "MOTOR_POWER")
latestHandleSignalsForMotorSlot = motorSlot
val left = parseHandleSignal("left", obj.optJSONObject("left"), leftSlot)
val right = parseHandleSignal("right", obj.optJSONObject("right"), rightSlot)
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", obj.optJSONObject("debugTrace")?.optBoolean("dongleStateLocated", false) ?: false),
observationBufferLocated = obj.optBoolean("observationBufferLocated", obj.optJSONObject("debugTrace")?.optBoolean("observationBufferLocated", false) ?: false),
dongle = dongle,
motorSlot = motorSlot,
leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs),
rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs),
dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs),
handleCacheAgeMs = handleCacheAgeMs
)
} catch (t: Throwable) {
Log.w(TAG, "updateHandleSignals exception: ${t.message}")
latestHandleSignalsForMotorSlot = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER", source = "parse-exception")
HandleDashboardSignal(
valid = false,
dongle = dongle,
leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs),
rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs),
dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs)
)
}
}
private fun parseHandleSignal(side: String, obj: JSONObject?, fallbackSlot: SlotSignal = SlotSignal()): HandleSignal {
if (obj == null) return HandleSignal(side, slot = fallbackSlot)
val embeddedSlot = SlotSignal(
slot = obj.optInt("slot", fallbackSlot.slot),
dev = obj.optInt("slotDev", fallbackSlot.dev),
name = firstNonBlank(obj.optString("slotName", ""), fallbackSlot.name),
state = firstNonBlank(obj.optString("slotState", ""), fallbackSlot.state),
connected = obj.optBoolean("slotConnected", fallbackSlot.connected),
lastSeenAgeMs = firstNonNegativeLong(obj.optLong("slotLastSeenAgeMs", Long.MIN_VALUE), fallbackSlot.lastSeenAgeMs),
source = "handleState.embeddedSlot"
)
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", ""),
slot = if (embeddedSlot.slot >= 0) embeddedSlot else fallbackSlot
)
}
private fun parseSlotSignal(slots: org.json.JSONArray?, expectedSlot: Int, expectedDev: Int, expectedName: String): SlotSignal {
if (slots == null) return SlotSignal(slot = expectedSlot, dev = expectedDev, name = expectedName, source = "no-slots")
for (i in 0 until slots.length()) {
val obj = slots.optJSONObject(i) ?: continue
val slot = obj.optInt("slot", -1)
val dev = obj.optInt("dev", 0)
val name = obj.optString("name", "")
if (slot == expectedSlot || dev == expectedDev || name.equals(expectedName, ignoreCase = true)) {
val state = obj.optString("state", "")
return SlotSignal(
slot = slot.takeIf { it >= 0 } ?: expectedSlot,
dev = dev.takeIf { it != 0 } ?: expectedDev,
name = firstNonBlank(name, expectedName),
state = state,
connected = state.equals("CONNECTED", ignoreCase = true) ||
state.equals("CONNECTING", ignoreCase = true) ||
state.equals("DISCOVERED", ignoreCase = true) ||
obj.optBoolean("connected", false),
lastSeenAgeMs = obj.optLong("lastSeenAgeMs", Long.MAX_VALUE),
source = "handleState.dongleSlots"
)
}
}
return SlotSignal(slot = expectedSlot, dev = expectedDev, name = expectedName, source = "slot-not-found")
}
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 stale = boolAny(
obj.optBoolean("stale", false),
obj.optBoolean("dataStale", false),
transport.optBoolean("stale", false),
transport.optBoolean("dataStale", false)
)
val dataStale = boolAny(
obj.optBoolean("dataStale", false),
transport.optBoolean("dataStale", false)
)
val lastUsbInAgeMs = firstNonNegativeLong(
transport.optLong("lastUsbInAgeMs", Long.MIN_VALUE),
obj.optLong("lastUsbInAgeMs", Long.MIN_VALUE)
)
val cacheAgeMs = obj.optLong("cacheAgeMs", Long.MAX_VALUE)
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 usbTrafficFresh = lastUsbInAgeMs in 0..DONGLE_TRANSPORT_FRESH_TIMEOUT_MS
val cacheFresh = cacheAgeMs == Long.MAX_VALUE || cacheAgeMs in 0..DONGLE_TRANSPORT_FRESH_TIMEOUT_MS
val effectiveStale = stale || !cacheFresh || !usbTrafficFresh
val effectiveDataStale = dataStale || !cacheFresh || !usbTrafficFresh
val active = usbPresent &&
usbBridgeRegistered &&
readLoopRunning &&
(activeFlags || stateActive) &&
!effectiveStale &&
usbTrafficFresh &&
cacheFresh
DongleSignal(
valid = true,
active = active,
usbPresent = usbPresent,
usbBridgeRegistered = usbBridgeRegistered,
readLoopRunning = readLoopRunning,
stale = effectiveStale,
dataStale = effectiveDataStale,
lastUsbInAgeMs = lastUsbInAgeMs,
cacheAgeMs = cacheAgeMs,
state = state,
source = obj.optString("source", "getDongleStateJson"),
evidence = "usbPresent=$usbPresent bridge=$usbBridgeRegistered readLoop=$readLoopRunning activeFlags=$activeFlags state=$state rawStale=$stale rawDataStale=$dataStale effectiveStale=$effectiveStale lastUsbInAgeMs=${printAge(lastUsbInAgeMs)} cacheAgeMs=${printAge(cacheAgeMs)}"
)
} 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 firstNonNegativeLong(vararg values: Long): Long {
return values.firstOrNull { it >= 0L } ?: Long.MAX_VALUE
}
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-v11.9 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)}; right.cacheAgeMs=${printAge(handles.handleCacheAgeMs)}; " +
"right.slotState=${handles.right.slot.state}; right.slotConnected=${handles.right.slot.connected}; right.slotAgeMs=${printAge(handles.right.slot.lastSeenAgeMs)}; " +
"motor.slotState=${handles.motorSlot.state}; motor.slotConnected=${handles.motorSlot.connected}; motor.slotAgeMs=${printAge(handles.motorSlot.lastSeenAgeMs)}; " +
"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}; " +
"dongle.stale=${handles.dongle.stale}; dongle.dataStale=${handles.dongle.dataStale}; dongle.lastUsbInAgeMs=${printAge(handles.dongle.lastUsbInAgeMs)}; dongle.cacheAgeMs=${printAge(handles.dongle.cacheAgeMs)}; " +
"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>
@@ -437,164 +64,168 @@ class ComponentRegistry(
result = "${oldState.name} -> ${newState.name}", result = "${oldState.name} -> ${newState.name}",
isStateChange = true isStateChange = true
)) ))
Log.i(TAG, "Phase4B-v11.9 availability_change; component=${type.name}; ${oldState.name}->${newState.name}")
} }
} }
} }
private fun checkAvailability( private fun checkAvailability(type: ComponentType): ComponentState {
type: ComponentType, dumpDebugOnce()
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, bound, runtimeStats, cameraJson) ComponentCategory.RUNTIME_HOST -> checkRuntimeHostComponent(type)
ComponentCategory.PERIPHERAL -> checkPeripheral(type, bound, handleJson, handles) ComponentCategory.PERIPHERAL -> checkPeripheral(type)
} }
} }
private fun checkRuntimeHostComponent( private fun checkRuntimeHostComponent(type: ComponentType): ComponentState {
type: ComponentType, // RuntimeHostStatus 始终可交互(Bind 按钮需在未绑定时可用)
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 (!bound) return ComponentState.GREY if (!repo.isBound()) return ComponentState.GREY
return when (type) { return when (type) {
// Phase4B-v11.9: do not let camera/home-card availability depend on a live camera ComponentType.CAMERA -> checkCamera()
// debug query during SDK_Panel cold start. Detail pages can still display richer state.
ComponentType.CAMERA -> ComponentState.ACTIVE
ComponentType.TELEMETRY_SAFETY -> ComponentState.ACTIVE ComponentType.TELEMETRY_SAFETY -> ComponentState.ACTIVE
else -> ComponentState.ACTIVE else -> ComponentState.ACTIVE
} }
} }
private fun checkPeripheral( private fun checkPeripheral(type: ComponentType): ComponentState {
type: ComponentType, if (!repo.isBound()) return ComponentState.GREY
bound: Boolean,
handleJson: String,
handles: HandleDashboardSignal
): ComponentState {
if (!bound) return ComponentState.GREY
return when (type) { return when (type) {
ComponentType.LEFT_HANDLE -> checkHandle(handles.left, handles.leftProgressAgeMs) ComponentType.LEFT_HANDLE -> checkHandle("left")
ComponentType.RIGHT_HANDLE -> checkHandle(handles.right, handles.rightProgressAgeMs) ComponentType.RIGHT_HANDLE -> checkHandle("right")
ComponentType.BALANCE_BOARD -> ComponentState.GREY // CoP not integrated yet. ComponentType.BALANCE_BOARD -> checkBalanceBoard()
ComponentType.DONGLE -> checkDongle(handles) ComponentType.DONGLE -> checkDongle()
ComponentType.MOTOR -> checkMotor() ComponentType.MOTOR -> checkMotor()
else -> ComponentState.GREY else -> ComponentState.GREY
} }
} }
private fun checkCamera(runtimeStats: FloatArray, cameraJson: String): ComponentState { private var debugDumped = false
// Home card means pipeline alive, not necessarily human detected.
val statsActive = runtimeStats.any { !it.isNaN() && !it.isInfinite() && it > 0.1f } private fun dumpDebugOnce() {
if (statsActive) return ComponentState.ACTIVE if (debugDumped || !repo.isBound()) return
if (isUnavailable(cameraJson)) return ComponentState.GREY 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 {
return try { return try {
val obj = JSONObject(cameraJson) val json = repo.getCameraStateJson()
val pipeline = obj.optJSONObject("realInternalCameraPipeline") if (isUnavailable(json)) ComponentState.GREY else ComponentState.ACTIVE
val pose2D = obj.optJSONObject("pose2DState") } catch (_: Exception) {
val pose3D = obj.optJSONObject("pose3DState") ComponentState.GREY
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 private fun checkHandle(side: String): ComponentState {
val videoPose3D = obj.optBoolean("videoPose3DEnabled", false) || pipeline?.optBoolean("videoPose3DEnabled", false) == true return try {
val pose2DAvailable = pose2D?.optBoolean("available", false) == true val json = repo.getHandleStateJson()
val pose3DAvailable = pose3D?.optBoolean("available", false) == true val obj = JSONObject(json)
if (running || imx415Open || (rga && rknn) || videoPose3D || pose2DAvailable || pose3DAvailable) { 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
) {
ComponentState.ACTIVE ComponentState.ACTIVE
} else { } else {
ComponentState.GREY ComponentState.GREY
} }
} catch (t: Throwable) { } catch (_: Exception) {
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 streamingActive = liveFlags && hasPayload && (dataFresh || sequenceProgressFresh)
val slotConnectedFresh = signal.slot.freshConnected
// Phase4B-v11.19: home card should distinguish BLE connection from IMU stream.
// A freshly connected Dongle slot is valid "connected" evidence even before
// handle IMU frames arrive; stale last-known payload still cannot keep it green.
val active = streamingActive || slotConnectedFresh
Log.i(
TAG,
"Phase4B-v11.9 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)}; " +
"slotState=${signal.slot.state}; slotConnected=${signal.slot.connected}; slotAgeMs=${printAge(signal.slot.lastSeenAgeMs)}; " +
"dataFresh=$dataFresh; sequenceProgressFresh=$sequenceProgressFresh; streamingActive=$streamingActive; slotConnectedFresh=$slotConnectedFresh; " +
"decision=${if (active) "ACTIVE" else "GREY"}; rule=REQUIRE_FRESH_DATA_OR_FRESH_SLOT_CONNECTION"
)
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)
// Phase4B-v11.17: Dongle card means SmartBase USB/CDC transport is actually alive.
// Do not mark it ACTIVE merely because RuntimeHost is bound, a placeholder exists,
// or a last-known Handle sample exists. Handles are useful detail-page slot evidence,
// but they are not sufficient transport evidence for the Dongle home card.
val transportActive = handles.dongle.valid && handles.dongle.active
val active = transportActive
val state = if (active) ComponentState.ACTIVE else ComponentState.GREY
latestDongleCardStateForMotorGate = state
Log.i(
TAG,
"Phase4B-v11.9 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}; " +
"stale=${handles.dongle.stale}; dataStale=${handles.dongle.dataStale}; lastUsbInAgeMs=${printAge(handles.dongle.lastUsbInAgeMs)}; cacheAgeMs=${printAge(handles.dongle.cacheAgeMs)}; " +
"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 signal.slot.freshConnected || (liveFlags && signal.sequenceId > 0L && signal.queueSize > 0 && (dataFresh || sequenceProgressFresh))
}
private fun checkMotor(): ComponentState { private fun checkMotor(): ComponentState {
// Phase4B-v11.14: Motor home-card state comes from explicit motor runtime-state return try {
// refreshed on a background thread, not from scatter-chart visibility. This remains // Motor is physically downstream of the SmartBase Dongle.
// no-blocking and can become ACTIVE before the user opens the Motor detail page. // V7 requires fresh motor telemetry; stale cached telemetry after Dongle re-plug must not turn Motor green.
val dongleActive = latestDongleCardStateForMotorGate == ComponentState.ACTIVE if (latestDongleCardStateForMotorGate != ComponentState.ACTIVE) return ComponentState.GREY
val motor = MotorTelemetrySnapshotCache.homeCardRuntimeStatus(MOTOR_STATUS_FRESH_TIMEOUT_MS)
val motorSlotConnectedFresh = latestHandleSignalsForMotorSlot?.freshConnected ?: false val json = repo.getLatestMotorStateJson()
val active = dongleActive && (motor.active || motorSlotConnectedFresh) if (isUnavailable(json)) return ComponentState.GREY
val state = if (active) ComponentState.ACTIVE else ComponentState.GREY
Log.i( val obj = JSONObject(json)
TAG, if (obj.optString("contractVersion", "") != "kiwii.motor-runtime-state.v1") return ComponentState.GREY
"Phase4B-v11.14 checkMotor; dongleActive=$dongleActive; " + if (obj.optBoolean("blockedByDongleTransport", false)) return ComponentState.GREY
"contractOk=${motor.contractOk}; available=${motor.available}; connected=${motor.connected}; state=${motor.state}; " + if (obj.optBoolean("blockedByMotorTelemetry", false)) return ComponentState.GREY
"slotState=${latestHandleSignalsForMotorSlot?.state ?: ""}; slotConnected=${latestHandleSignalsForMotorSlot?.connected ?: false}; slotAgeMs=${printAge(latestHandleSignalsForMotorSlot?.lastSeenAgeMs ?: Long.MAX_VALUE)}; " + if (!obj.optBoolean("available", false)) return ComponentState.GREY
"blockedByDongleTransport=${motor.blockedByDongleTransport}; blockedByMotorTelemetry=${motor.blockedByMotorTelemetry}; " +
"dataAgeMs=${printAge(motor.dataAgeMs)}; snapshotAgeMs=${printAge(motor.snapshotAgeMs)}; " + val dataAgeMs = obj.optLong("dataAgeMs", Long.MAX_VALUE)
"reason=${if (motor.active) motor.reason else if (motorSlotConnectedFresh) "MOTOR_SLOT_CONNECTED_NO_TELEMETRY" else motor.reason}; decision=${state.name}; rule=EXPLICIT_MOTOR_RUNTIME_STATE_OR_FRESH_SLOT_CONNECTION" if (dataAgeMs < 0L || dataAgeMs > MOTOR_TELEMETRY_FRESH_TIMEOUT_MS) return ComponentState.GREY
)
return state 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,10 +14,8 @@ 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 -> "{\"contractVersion\":\"kiwii.sdk-panel.balance-placeholder.v1\",\"available\":false,\"reason\":\"Balance Board detail state is not wired yet\"}" ComponentType.BALANCE_BOARD -> bridge.getDebugSnapshotJson()
// Phase4B-v11: Dongle detail state must include transport + slot0..3 + handle-state inference. ComponentType.DONGLE -> bridge.getDongleStateJson()
// 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,13 +26,6 @@ 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) {
@@ -48,7 +41,7 @@ class StatePoller(
isStateChange = true isStateChange = true
)) ))
} }
val snapshot = pollFocusedStates() val snapshot = pollAllStates()
_states.value = snapshot _states.value = snapshot
} else { } else {
if (wasBound) { if (wasBound) {
@@ -69,17 +62,19 @@ class StatePoller(
} }
} }
private fun pollFocusedStates(): Map<ComponentType, StateSnapshot> { private fun pollAllStates(): Map<ComponentType, StateSnapshot> {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// Phase4B-v11: detail pages must not poll every component. The previous all-component val polledTypes = listOf(
// loop called heavy/global APIs such as getDebugSnapshotJson and could starve/hang the ComponentType.RUNTIME_HOST_STATUS,
// current page after back/re-enter. Poll RuntimeHost + the currently visible component only. ComponentType.CAMERA,
val types = linkedSetOf(ComponentType.RUNTIME_HOST_STATUS) ComponentType.LEFT_HANDLE,
if (focusedComponent != ComponentType.RUNTIME_HOST_STATUS && focusedComponent != ComponentType.SESSION_LOG) { ComponentType.RIGHT_HANDLE,
types += focusedComponent ComponentType.BALANCE_BOARD,
} ComponentType.DONGLE,
ComponentType.MOTOR,
return types.mapNotNull { type -> ComponentType.TELEMETRY_SAFETY
)
return polledTypes.mapNotNull { type ->
try { try {
type to StateSnapshot( type to StateSnapshot(
data = runtimeStateRepo.getComponentState(type), data = runtimeStateRepo.getComponentState(type),
@@ -87,6 +82,7 @@ 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,
@@ -96,7 +92,7 @@ class StatePoller(
isStateChange = false isStateChange = false
)) ))
type to StateSnapshot( type to StateSnapshot(
data = "{\"error\":\"${escapeJson(e.message ?: e.javaClass.simpleName)}\"}", data = "{\"error\":\"${e.message}\"}",
updatedAt = now, updatedAt = now,
isDebugChannel = runtimeStateRepo.isUsingDebugChannel(), isDebugChannel = runtimeStateRepo.isUsingDebugChannel(),
hasError = true hasError = true
@@ -105,8 +101,6 @@ class StatePoller(
}.toMap() }.toMap()
} }
private fun escapeJson(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"")
fun stopPolling() { fun stopPolling() {
scope.cancel() scope.cancel()
} }
@@ -1,6 +1,5 @@
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
@@ -33,7 +32,6 @@ 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,31 +16,25 @@ 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 = "Phase 4A Camera Observation\n2D/3D are split into LatestFastState + ObservationBuffer. 3D uses VideoPose3D 27F. Timestamps use hostArrivalNs / hostEstimatedSampleTimeNs." text = "Preview: Pending RuntimeHost debug channel"
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("Camera Observation Display")) layout.addView(createSection("SDK Operations"))
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("getHealthStateJson") { layout.addView(createButton("getPerceptionStateJson") {
viewModel.executeOperation(ComponentType.CAMERA, "getHealthStateJson") { viewModel.executeOperation(ComponentType.CAMERA, "getPerceptionStateJson") {
viewModel.bridgeRepo.getHealthStateJson() viewModel.bridgeRepo.getPerceptionStateJson()
} }
}) })
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()
@@ -56,13 +50,6 @@ 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,12 +13,30 @@ class DongleOps(
override fun createView(): View { override fun createView(): View {
val layout = verticalLayout() val layout = verticalLayout()
// Phase4B-v11: one action only. It reads the SDK Panel detail-state JSON and updates layout.addView(createSection("Dongle Transport"))
// both State panel and Session Log. It does not mutate RuntimeHost state. layout.addView(createButton("getDongleStateJson") {
layout.addView(createSection("Dongle Diagnostics")) viewModel.executeOperation(ComponentType.DONGLE, "getDongleStateJson") {
layout.addView(createButton("readDongleState") { viewModel.bridgeRepo.getDongleStateJson()
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()
} }
}) })
@@ -1,63 +1,208 @@
package com.kiwii.controlpanel.ui.components package com.kiwii.controlpanel.ui.components
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.InputType
import android.view.Gravity
import android.view.View import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Spinner import android.widget.Spinner
import android.widget.TextView
import com.kiwii.controlpanel.model.ComponentType import com.kiwii.controlpanel.model.ComponentType
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
import org.json.JSONObject
class MotorOps( class MotorOps(
context: Context, context: Context,
private val viewModel: ComponentDetailViewModel private val viewModel: ComponentDetailViewModel
) : BaseOps(context) { ) : BaseOps(context) {
private val trainingModes = arrayOf( private data class ParamDef(
"NONE (0)", "FREE_WEIGHT (1)", "ECCENTRIC_OVERLOAD (2)", val key: String,
"VISCOUS (3)", "ELASTIC (4)", "ISOKINETIC_SPOTTING (5)", "SPOTTER (6)" val label: String,
val defaultValue: String,
val unit: String = "",
val definition: String = ""
) )
private data class ModeDef(
val mode: Int,
val name: String,
val description: String,
val params: List<ParamDef>
) {
override fun toString(): String = "$name ($mode)"
}
private val spotterParams = listOf(
ParamDef("fm_sp_v", "Spotter velocity threshold", "0.1", "m/s", "Spotter 触发速度阈值;低于该速度并满足时间/位置条件时进入保护减重逻辑。"),
ParamDef("fm_sp_t", "Spotter trigger time", "5.0", "s", "Spotter 触发时间阈值;持续满足触发条件超过该时间后启动保护。"),
ParamDef("fm_sp_d", "Spotter decay duration", "5.0", "s", "Spotter 减重衰减时长;保护模式下降低负重的时间尺度。"),
ParamDef("fm_sp_home", "Spotter home", "0.0", "m", "Spotter 参考初始位置;用于判断用户是否离开安全范围。"),
ParamDef("fm_sp_rng", "Spotter load range", "-0.10", "m", "Spotter 负重判定位置阈值;通常为负值,表示相对 home 的保护触发位置范围。"),
ParamDef("fm_sp_rec", "Spotter recovery", "1.0", "s", "Spotter 恢复时长;保护结束后恢复到目标负重的时间尺度。")
)
private val pulleyParam = ParamDef("fm_rad", "Pulley radius", "0.04", "m", "滑轮/卷筒有效半径;输出扭矩 = 输出力 × fm_rad。该值直接影响力和绳长估算。")
private val modes = listOf(
ModeDef(0, "None", "Output force is zero.", emptyList()),
ModeDef(1, "FreeWeight", "Free weight / inertia compensation.", listOf(
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;FreeWeight 下相当于目标训练重量,不等于独立拉力传感器测得值。"),
ParamDef("fm_kin", "Inertia ratio", "0.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"),
ParamDef("fm_bfr", "Linear friction", "0.0", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"),
pulleyParam
)),
ModeDef(2, "EccentricOverload", "Eccentric overload with smooth concentric/eccentric switching.", listOf(
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;EccentricOverload 下作为向心阶段基准目标负重。"),
ParamDef("fm_kin", "Inertia ratio", "1.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"),
ParamDef("fm_bfr", "Linear friction", "0.05", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"),
ParamDef("fm_kecc", "Eccentric multiplier", "1.5", "", "离心倍率;离心阶段目标力 = fm_kecc × 向心阶段目标力。"),
ParamDef("fm_vth", "Velocity threshold", "0.3", "m/s", "离心/向心平滑切换速度阈值;用于避免速度方向切换时力突变。"),
pulleyParam
) + spotterParams),
ModeDef(3, "Viscous", "Viscous / fluid resistance.", listOf(
ParamDef("fm_cdrv", "Drive damping", "0.1", "N/(m/s)^2", "粘滞拉出方向平方阻尼系数;拉出速度越大阻尼增长越快。"),
ParamDef("fm_crec", "Recovery damping", "0.1", "N/(m/s)", "粘滞回收方向线性阻尼系数;回收阶段按速度线性给阻尼。"),
pulleyParam
)),
ModeDef(4, "Elastic", "Elastic spring mode.", listOf(
ParamDef("fm_k", "Spring stiffness", "0.0", "N/m", "弹性刚度;输出力随位置偏移按弹簧模型变化。"),
ParamDef("fm_x0", "Zero position", "0.0", "m", "弹性零点位置;rope length 与该位置的差决定弹性力。"),
pulleyParam
)),
ModeDef(5, "IsokineticSpotting", "Isokinetic velocity wall plus spotting.", listOf(
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;作为等速保护和速度墙前的基础阻力。"),
ParamDef("fm_vmax", "Max velocity", "1.0", "m/s", "等速模式最大速度;超过该速度时由速度墙施加额外阻力。"),
ParamDef("fm_gwall", "Velocity wall gain", "0.0", "N/(m/s)", "等速速度墙增益;超出 fm_vmax 后按速度误差增加阻力。"),
pulleyParam
) + spotterParams),
ModeDef(6, "Spotter", "Spotter protection mode.", listOf(
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;Spotter 模式下作为保护逻辑的基础负重。"),
ParamDef("fm_kin", "Inertia ratio", "0.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"),
ParamDef("fm_bfr", "Linear friction", "0.0", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"),
pulleyParam
) + spotterParams)
)
private val signedDecimalInputType = InputType.TYPE_CLASS_NUMBER or
InputType.TYPE_NUMBER_FLAG_DECIMAL or
InputType.TYPE_NUMBER_FLAG_SIGNED
override fun createView(): View { override fun createView(): View {
val layout = verticalLayout() val layout = verticalLayout()
layout.addView(createSection("Weight")) layout.addView(createSection("Realtime Telemetry"))
val weightInput = createParameterInput( layout.addView(MotorRealtimeTelemetryPanel(context, viewModel.bridgeRepo))
"Weight (kg)",
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL
)
val weightAction = createButton("setMotorWeightKg") {
val weight = weightInput.text.toString().toFloatOrNull() ?: 0f
viewModel.executeOperation(ComponentType.MOTOR, "setMotorWeightKg", "weight=$weight") {
viewModel.bridgeRepo.setMotorWeightKg(weight)
}
}
layout.addView(createParameterActionRow(weightAction, weightInput))
layout.addView(createSection("Training Mode")) layout.addView(createSection("Force Control Parameters"))
val modeSpinner = Spinner(context).apply { val modeSpinner = Spinner(context).apply {
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, trainingModes) adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, modes)
} }
val paramInput = createParameterInput( layout.addView(modeSpinner)
"Param (float)",
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL
)
val trainingModeAction = createButton("setMotorTrainingMode") {
val mode = modeSpinner.selectedItemPosition
val param = paramInput.text.toString().toFloatOrNull() ?: 0f
viewModel.executeOperation(ComponentType.MOTOR, "setMotorTrainingMode", "mode=$mode,param=$param") {
viewModel.bridgeRepo.setMotorTrainingMode(mode, param)
}
}
layout.addView(createParameterActionRow(trainingModeAction, modeSpinner, paramInput))
val axisInput = createParameterInput("Axis (int)", android.text.InputType.TYPE_CLASS_NUMBER) val description = TextView(context).apply {
val queryModeAction = createButton("queryMotorTrainingMode") { textSize = 10f
val axis = axisInput.text.toString().toIntOrNull() ?: 0 setPadding(6.dp(), 4.dp(), 6.dp(), 4.dp())
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "axis=$axis") { }
viewModel.bridgeRepo.queryMotorTrainingMode(axis) layout.addView(description)
val paramContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
}
layout.addView(paramContainer)
val definitionText = TextView(context).apply {
textSize = 9.5f
setPadding(6.dp(), 4.dp(), 6.dp(), 8.dp())
}
layout.addView(definitionText)
val editTexts = LinkedHashMap<String, EditText>()
fun rebuildParamForm(mode: ModeDef) {
description.text = "Mode ${mode.mode}: ${mode.description}"
paramContainer.removeAllViews()
editTexts.clear()
if (mode.params.isEmpty()) {
val noneText = TextView(context).apply {
text = "fm_mode = 0. No extra parameters. Applying this mode sends {\"fm_mode\":0}."
textSize = 10f
typeface = Typeface.MONOSPACE
setPadding(6.dp(), 4.dp(), 6.dp(), 4.dp())
}
paramContainer.addView(noneText)
definitionText.text = "fm_mode: 力控模式。None = 0,输出力为 0。"
return
}
mode.params.chunked(2).forEach { rowParams ->
val rowViews = rowParams.map { param ->
createLabeledParameterInput(param).also { labeled ->
val editText = labeled.findViewWithTag<EditText>("input:${param.key}")
editTexts[param.key] = editText
} }
} }
layout.addView(createParameterActionRow(queryModeAction, axisInput)) paramContainer.addView(createParameterRow(*rowViews.toTypedArray()))
}
definitionText.text = buildString {
append("Parameter definitions:\n")
append("fm_mode: 力控模式,当前选择 ${mode.name} (${mode.mode})。\n")
mode.params.forEach { param ->
append(param.key)
append(": ")
append(if (param.definition.isNotBlank()) param.definition else param.label)
if (param.unit.isNotBlank()) {
append(" [")
append(param.unit)
append("]")
}
append('\n')
}
append("\nNote: fm_mset 是目标虚拟负重;实时 Force kgf 是由电机电流/扭矩估算的张力等效重量,不是第三方拉力传感器读数。")
}.trim()
}
modeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
rebuildParamForm(modes[position])
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
rebuildParamForm(modes.first())
val applyAction = createButton("setMotorForceControlParamsJson") {
val mode = modes[modeSpinner.selectedItemPosition]
val payload = JSONObject().apply {
put("fm_mode", mode.mode)
for ((key, editText) in editTexts) {
val value = editText.text.toString().trim().toDoubleOrNull()
if (value != null) put(key, value)
}
}.toString()
viewModel.executeOperation(ComponentType.MOTOR, "setMotorForceControlParamsJson", payload) {
viewModel.bridgeRepo.setMotorForceControlParamsJson(payload)
}
}
layout.addView(applyAction)
val queryCurrentAction = createButton("queryMotorTrainingMode current") {
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "mode=-1") {
viewModel.bridgeRepo.queryMotorTrainingMode(-1)
}
}
val querySelectedAction = createButton("queryMotorTrainingMode selected") {
val mode = modes[modeSpinner.selectedItemPosition]
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "mode=${mode.mode}") {
viewModel.bridgeRepo.queryMotorTrainingMode(mode.mode)
}
}
layout.addView(queryCurrentAction)
layout.addView(querySelectedAction)
layout.addView(createSection("State Queries")) layout.addView(createSection("State Queries"))
layout.addView(createButton("getLatestMotorStateJson") { layout.addView(createButton("getLatestMotorStateJson") {
@@ -70,7 +215,47 @@ class MotorOps(
viewModel.bridgeRepo.getMotorControlStateJson() viewModel.bridgeRepo.getMotorControlStateJson()
} }
}) })
layout.addView(createButton("getMotorDisconnectDiagnosisJson") {
viewModel.executeOperation(ComponentType.MOTOR, "getMotorDisconnectDiagnosisJson") {
viewModel.bridgeRepo.getMotorDisconnectDiagnosisJson()
}
})
layout.addView(createButton("sendMotorHeartbeat") {
viewModel.executeOperation(ComponentType.MOTOR, "sendMotorHeartbeat") {
viewModel.bridgeRepo.sendMotorHeartbeat()
}
})
return layout return layout
} }
private fun createLabeledParameterInput(param: ParamDef): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(2.dp(), 0, 2.dp(), 0)
addView(TextView(context).apply {
text = "${param.key} ="
textSize = 10.5f
typeface = Typeface.MONOSPACE
setTextColor(Color.rgb(38, 50, 56))
gravity = Gravity.CENTER_VERTICAL
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 0.95f))
addView(EditText(context).apply {
tag = "input:${param.key}"
inputType = signedDecimalInputType
setSingleLine(true)
setText(param.defaultValue)
hint = param.unit.ifBlank { param.label }
textSize = 11f
typeface = Typeface.MONOSPACE
setPadding(4.dp(), 0, 4.dp(), 0)
minHeight = 34.dp()
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1.05f))
}
}
private fun Int.dp(): Int = (this * context.resources.displayMetrics.density).toInt()
} }
@@ -5,8 +5,8 @@ import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log
import android.view.Gravity import android.view.Gravity
import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.kiwii.controlpanel.R import com.kiwii.controlpanel.R
@@ -15,19 +15,6 @@ import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* Phase4B-v11.17 Motor chart lifecycle/cache-poll fix.
*
* Design:
* - Do not change Motor home-card connectivity semantics.
* - Do not change Dongle / Handle / Camera / Session Log behavior.
* - Keep the v11.14 explicit Motor runtime-state cache as the source of truth.
* - Fix Motor page re-entry by restarting the chart poller from onAttachedToWindow().
* - Keep the poller off the UI thread and catch all failures so one timeout/exception does
* not permanently kill the chart refresh loop.
* - Do not let the chart scheduler block on a live Binder call. It requests the shared
* MotorTelemetrySnapshotCache refresh asynchronously, then renders the last-known fresh sample.
*/
internal class MotorRealtimeTelemetryPanel( internal class MotorRealtimeTelemetryPanel(
context: Context, context: Context,
private val repo: BridgeRepository private val repo: BridgeRepository
@@ -38,17 +25,8 @@ internal class MotorRealtimeTelemetryPanel(
private val headline = TextView(context) private val headline = TextView(context)
private val temperatureLine = TextView(context) private val temperatureLine = TextView(context)
private val subline = TextView(context) private val subline = TextView(context)
@Volatile
private var executor: ScheduledExecutorService? = null private var executor: ScheduledExecutorService? = null
@Volatile
private var attached = false
private var renderedCount: Long = 0L
private var lastRenderedWallTimeMs: Long = -1L
private var lastPollLogMs: Long = 0L
init { init {
orientation = VERTICAL orientation = VERTICAL
setBackgroundResource(R.drawable.bg_rhs_log_area) setBackgroundResource(R.drawable.bg_rhs_log_area)
@@ -78,7 +56,7 @@ internal class MotorRealtimeTelemetryPanel(
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) }, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(subline.apply { addView(subline.apply {
text = "Phase4B-v11.17 cached telemetry poll; buttons use last-known snapshot" text = "Polling RuntimeHost getLatestMotorStateJson() at 5 Hz"
textSize = 10f textSize = 10f
setTextColor(Color.rgb(96, 111, 123)) setTextColor(Color.rgb(96, 111, 123))
setPadding(0, 2.dp(), 0, 6.dp()) setPadding(0, 2.dp(), 0, 6.dp())
@@ -97,96 +75,55 @@ internal class MotorRealtimeTelemetryPanel(
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
attached = true
startPolling() startPolling()
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
attached = false
stopPolling() stopPolling()
super.onDetachedFromWindow() super.onDetachedFromWindow()
} }
private fun startPolling() { private fun startPolling() {
val current = executor if (executor != null) return
if (current != null && !current.isShutdown && !current.isTerminated) return
Log.i(TAG, "Phase4B-v11.17 MotorTelemetryPanel start; cachePoll=true; directBinderOnScheduler=false; lifecycleSafe=true")
executor = Executors.newSingleThreadScheduledExecutor { runnable -> executor = Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "kiwii-motor-telemetry-panel-v11.17").apply { isDaemon = true } Thread(runnable, "kiwii-motor-telemetry-panel").apply { isDaemon = true }
}.also { exec -> }.also { exec ->
exec.scheduleWithFixedDelay({ exec.scheduleAtFixedRate({ pollOnce() }, 0L, 200L, TimeUnit.MILLISECONDS)
runCatching { pollOnce() }
.onFailure { t ->
val reason = t.message ?: t.javaClass.simpleName
MotorTelemetrySnapshotCache.recordMotorStateError("motor-chart-poll failed: $reason")
Log.w(TAG, "Phase4B-v11.17 MotorTelemetryPanel poll failed: $reason")
postUnavailable("chart poll failed: $reason")
}
}, 0L, 200L, TimeUnit.MILLISECONDS)
} }
} }
private fun stopPolling() { private fun stopPolling() {
Log.i(TAG, "Phase4B-v11.17 MotorTelemetryPanel stop")
executor?.shutdownNow() executor?.shutdownNow()
executor = null executor = null
mainHandler.removeCallbacksAndMessages(null) mainHandler.removeCallbacksAndMessages(null)
} }
private fun pollOnce() { private fun pollOnce() {
if (!attached) return
if (!repo.isBound()) { if (!repo.isBound()) {
postUnavailable("RuntimeHost not bound") mainHandler.post { setUnavailable("RuntimeHost not bound") }
return return
} }
val json = try {
// This call is non-blocking for the chart scheduler. The actual Binder read runs in repo.getLatestMotorStateJson()
// MotorTelemetrySnapshotCache's daemon refresh thread with stuck-refresh recovery. } catch (t: Throwable) {
MotorTelemetrySnapshotCache.requestRuntimeStateRefreshAsync(repo, "motor-chart-poll") mainHandler.post { setUnavailable(t.message ?: t.javaClass.simpleName) }
return
val snapshot = MotorTelemetrySnapshotCache.chartSnapshot(freshTimeoutMs = 2_000L) }
val sample = snapshot.sample val sample = MotorTelemetryParser.parse(json)
mainHandler.post {
if (sample == null) { if (sample == null) {
val reason = "${snapshot.reason}; effectiveAgeMs=${printAge(snapshot.effectiveAgeMs)}; snapshotAgeMs=${printAge(snapshot.snapshotAgeMs)}" setUnavailable("No fresh motor telemetry")
logPollNoSample(reason)
postUnavailable(reason)
} else { } else {
if (sample.wallTimeMs == lastRenderedWallTimeMs) { updateSample(sample)
logPollNoSample("NO_NEW_SAMPLE; effectiveAgeMs=${printAge(snapshot.effectiveAgeMs)}; snapshotAgeMs=${printAge(snapshot.snapshotAgeMs)}")
} }
postSample(sample, snapshot.effectiveAgeMs)
} }
} }
private fun postSample(sample: MotorTelemetrySample, effectiveAgeMs: Long) { private fun updateSample(sample: MotorTelemetrySample) {
mainHandler.post {
if (!attached) return@post
updateSample(sample, effectiveAgeMs)
}
}
private fun postUnavailable(reason: String) {
mainHandler.post {
if (!attached) return@post
setUnavailable(reason)
}
}
private fun updateSample(sample: MotorTelemetrySample, effectiveAgeMs: Long) {
headline.text = "Force: ${fmt(sample.forceKg)} kgf Rope: ${fmt(sample.ropeLengthM * 100.0)} cm" headline.text = "Force: ${fmt(sample.forceKg)} kgf Rope: ${fmt(sample.ropeLengthM * 100.0)} cm"
temperatureLine.text = "Temp: ${fmt(sample.tempC)} °C" temperatureLine.text = "Temp: ${fmt(sample.tempC)} °C"
subline.text = "Force=${fmt(sample.forceN)} N I=${fmt(sample.currentA)} A V=${fmt(sample.voltageV)} V age=${printAge(effectiveAgeMs)} ms" subline.text = "Force=${fmt(sample.forceN)} N I=${fmt(sample.currentA)} A V=${fmt(sample.voltageV)} V age=${sample.dataAgeMs} ms"
if (sample.wallTimeMs != lastRenderedWallTimeMs) {
chartView.addSample(sample) chartView.addSample(sample)
lastRenderedWallTimeMs = sample.wallTimeMs
renderedCount += 1L
if (renderedCount == 1L || renderedCount % 10L == 0L) {
Log.i(TAG, "Phase4B-v11.17 MotorTelemetryPanel render; count=$renderedCount; sampleWallTimeMs=${sample.wallTimeMs}; dataAgeMs=${sample.dataAgeMs}; effectiveAgeMs=$effectiveAgeMs")
}
}
} }
private fun setUnavailable(reason: String) { private fun setUnavailable(reason: String) {
@@ -195,17 +132,6 @@ internal class MotorRealtimeTelemetryPanel(
subline.text = reason subline.text = reason
} }
private fun logPollNoSample(reason: String) {
val now = System.currentTimeMillis()
if (now - lastPollLogMs < 1000L) return
lastPollLogMs = now
Log.i(TAG, "Phase4B-v11.17 MotorTelemetryPanel poll; running=true; newSample=false; lastRenderedSampleWallTimeMs=$lastRenderedWallTimeMs; reason=$reason")
}
private fun printAge(ageMs: Long): String {
return if (ageMs == Long.MAX_VALUE) "NA" else ageMs.toString()
}
private fun fmt(value: Double): String { private fun fmt(value: Double): String {
if (!value.isFinite()) return "" if (!value.isFinite()) return ""
val absValue = kotlin.math.abs(value) val absValue = kotlin.math.abs(value)
@@ -217,8 +143,4 @@ internal class MotorRealtimeTelemetryPanel(
} }
private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt() private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt()
private companion object {
const val TAG = "KiwiiSDKPanelMotor"
}
} }
@@ -1,26 +1,8 @@
package com.kiwii.controlpanel.ui.components package com.kiwii.controlpanel.ui.components
import com.kiwii.controlpanel.data.BridgeRepository
import com.kiwii.controlpanel.logging.LogEntry
import com.kiwii.controlpanel.logging.SessionLogger
import com.kiwii.controlpanel.model.ComponentType
import org.json.JSONObject import org.json.JSONObject
import kotlin.math.abs import kotlin.math.abs
internal data class MotorHomeCardRuntimeStatus(
val active: Boolean,
val contractOk: Boolean,
val available: Boolean,
val connected: Boolean,
val state: String,
val blockedByDongleTransport: Boolean,
val blockedByMotorTelemetry: Boolean,
val dataAgeMs: Long,
val snapshotAgeMs: Long,
val reason: String
)
internal data class MotorTelemetrySample( internal data class MotorTelemetrySample(
val wallTimeMs: Long, val wallTimeMs: Long,
val motorRuntimeSec: Double, val motorRuntimeSec: Double,
@@ -35,15 +17,6 @@ internal data class MotorTelemetrySample(
val rawJson: String val rawJson: String
) )
internal data class MotorTelemetryChartSnapshot(
val sample: MotorTelemetrySample?,
val rawJson: String?,
val effectiveAgeMs: Long,
val snapshotAgeMs: Long,
val lastError: String?,
val reason: String
)
internal object MotorTelemetryParser { internal object MotorTelemetryParser {
private const val STANDARD_GRAVITY_MPS2 = 9.80665 private const val STANDARD_GRAVITY_MPS2 = 9.80665
private const val DEFAULT_PULLEY_RADIUS_M = 0.04 private const val DEFAULT_PULLEY_RADIUS_M = 0.04
@@ -108,405 +81,3 @@ internal object MotorTelemetryParser {
return fallback return fallback
} }
} }
/**
* Phase4B-v11.11 Motor safe UI snapshot cache.
*
* This cache is intentionally local to the Motor UI component package. It avoids changing
* ComponentRegistry, BridgeRepository, Handle, Dongle, Camera, or RuntimeHost status logic.
*
* RuntimeHost motor debug/query methods have been observed to block the SDK Panel operation
* timeout path after idle. The Motor page therefore keeps the chart polling on its own daemon
* thread and exposes non-blocking last-known results for UI buttons.
*/
internal object MotorTelemetrySnapshotCache {
private val lock = Any()
private var lastRawJson: String? = null
private var lastSample: MotorTelemetrySample? = null
private var lastUpdateMs: Long = 0L
private var lastError: String? = null
private var lastErrorMs: Long = 0L
private var lastCommandResult: String? = null
private var lastCommandAtMs: Long = 0L
private val asyncLock = Any()
private var refreshInFlight: Boolean = false
private var lastRefreshAttemptMs: Long = 0L
private const val REFRESH_MIN_INTERVAL_MS = 500L
private const val REFRESH_STUCK_RESET_MS = 15_000L
fun recordMotorStateJson(json: String, sample: MotorTelemetrySample?) {
synchronized(lock) {
lastRawJson = json
lastSample = sample
lastUpdateMs = System.currentTimeMillis()
lastError = null
lastErrorMs = 0L
}
}
fun recordMotorStateError(reason: String) {
synchronized(lock) {
lastError = reason.ifBlank { "unknown" }
lastErrorMs = System.currentTimeMillis()
}
}
/**
* Phase4B-v11.14: refresh explicit motor runtime-state on a background thread.
*
* Home-card Motor availability must be based on RuntimeHost motor state, not on whether
* the Motor detail page / scatter chart is currently visible. This method never blocks
* the SDK Panel availability loop; if RuntimeHost's motor query stalls after idle, the
* last-known snapshot remains available and the UI stays responsive.
*/
fun requestRuntimeStateRefreshAsync(repo: BridgeRepository, reason: String = "") {
if (!repo.isBound()) return
val now = System.currentTimeMillis()
var shouldLaunch = false
synchronized(asyncLock) {
val stuck = refreshInFlight && lastRefreshAttemptMs > 0L && now - lastRefreshAttemptMs > REFRESH_STUCK_RESET_MS
if (!refreshInFlight || stuck) {
if (now - lastRefreshAttemptMs >= REFRESH_MIN_INTERVAL_MS || stuck) {
refreshInFlight = true
lastRefreshAttemptMs = now
shouldLaunch = true
}
}
}
if (!shouldLaunch) return
Thread({
try {
val json = repo.getLatestMotorStateJson()
val sample = MotorTelemetryParser.parse(json)
recordMotorStateJson(json, sample)
} catch (t: Throwable) {
recordMotorStateError("motor-state-refresh failed; reason=$reason; ${t.message ?: t.javaClass.simpleName}")
} finally {
synchronized(asyncLock) {
refreshInFlight = false
}
}
}, "kiwii-motor-state-refresh-v11.14").apply { isDaemon = true }.start()
}
fun submitForceControlParamsAsync(repo: BridgeRepository, paramsJson: String): String {
val requestId = "sdkpanel-force-${System.currentTimeMillis()}"
Thread({
val started = System.currentTimeMillis()
val result = try {
repo.setMotorForceControlParamsJson(paramsJson)
} catch (t: Throwable) {
JSONObject().put("error", "setMotorForceControlParamsJson async failed: ${t.message ?: t.javaClass.simpleName}").toString()
}
recordCommandResult("setMotorForceControlParamsJson", result)
SessionLogger.log(LogEntry(
timestamp = System.currentTimeMillis(),
component = ComponentType.MOTOR,
action = "setMotorForceControlParamsJson.asyncResult",
params = "requestId=$requestId; elapsedMs=${System.currentTimeMillis() - started}",
result = result
))
requestRuntimeStateRefreshAsync(repo, "after-force-control-command")
}, "kiwii-motor-force-command-v11.14").apply { isDaemon = true }.start()
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-command-queued.v1")
put("uiPath", "ASYNC_COMMAND_NO_UI_TIMEOUT")
put("requestId", requestId)
put("action", "setMotorForceControlParamsJson")
put("queued", true)
val parsedParams = runCatching { JSONObject(paramsJson) }.getOrNull()
if (parsedParams != null) put("params", parsedParams) else put("paramsRaw", paramsJson)
put("note", "Command is sent on a background thread so the Motor UI does not show a false 2000ms timeout after idle. The async result is appended to the Motor Session Log.")
}.toString(2)
}
fun sendMotorHeartbeatAsync(repo: BridgeRepository): String {
val requestId = "sdkpanel-heartbeat-${System.currentTimeMillis()}"
Thread({
val started = System.currentTimeMillis()
val result = try {
repo.sendMotorHeartbeat()
} catch (t: Throwable) {
JSONObject().put("error", "sendMotorHeartbeat async failed: ${t.message ?: t.javaClass.simpleName}").toString()
}
recordCommandResult("sendMotorHeartbeat", result)
SessionLogger.log(LogEntry(
timestamp = System.currentTimeMillis(),
component = ComponentType.MOTOR,
action = "sendMotorHeartbeat.asyncResult",
params = "requestId=$requestId; elapsedMs=${System.currentTimeMillis() - started}",
result = result
))
requestRuntimeStateRefreshAsync(repo, "after-heartbeat")
}, "kiwii-motor-heartbeat-command-v11.14").apply { isDaemon = true }.start()
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-command-queued.v1")
put("uiPath", "ASYNC_COMMAND_NO_UI_TIMEOUT")
put("requestId", requestId)
put("action", "sendMotorHeartbeat")
put("queued", true)
}.toString(2)
}
private fun recordCommandResult(action: String, result: String) {
synchronized(lock) {
lastCommandResult = "$action => ${result.take(500)}"
lastCommandAtMs = System.currentTimeMillis()
}
}
fun homeCardRuntimeStatus(freshTimeoutMs: Long): MotorHomeCardRuntimeStatus {
val snapshot = snapshot()
val raw = snapshot.rawJson
if (raw.isNullOrBlank()) {
return MotorHomeCardRuntimeStatus(
active = false,
contractOk = false,
available = false,
connected = false,
state = "NO_RAW_MOTOR_STATE",
blockedByDongleTransport = false,
blockedByMotorTelemetry = false,
dataAgeMs = Long.MAX_VALUE,
snapshotAgeMs = snapshot.snapshotAgeMs,
reason = snapshot.lastError ?: "NO_MOTOR_RUNTIME_STATE_SNAPSHOT"
)
}
val obj = runCatching { JSONObject(raw) }.getOrNull()
?: return MotorHomeCardRuntimeStatus(
active = false,
contractOk = false,
available = false,
connected = false,
state = "RAW_MOTOR_STATE_NOT_JSON",
blockedByDongleTransport = false,
blockedByMotorTelemetry = false,
dataAgeMs = Long.MAX_VALUE,
snapshotAgeMs = snapshot.snapshotAgeMs,
reason = "RAW_MOTOR_STATE_NOT_JSON"
)
val contractOk = obj.optString("contractVersion", "") == "kiwii.motor-runtime-state.v1"
val available = obj.optBoolean("available", false)
val connected = obj.optBoolean("connected", false) ||
obj.optBoolean("motorConnected", false) ||
obj.optBoolean("online", false)
val state = obj.optString("state", obj.optString("motorState", ""))
val stateLooksConnected = state.equals("CONNECTED", ignoreCase = true) ||
state.equals("ACTIVE", ignoreCase = true) ||
state.equals("RUNNING", ignoreCase = true) ||
state.equals("ONLINE", ignoreCase = true)
val blockedByDongleTransport = obj.optBoolean("blockedByDongleTransport", false)
val blockedByMotorTelemetry = obj.optBoolean("blockedByMotorTelemetry", false)
val rawDataAgeMs = obj.optLong("dataAgeMs", Long.MAX_VALUE)
val dataAgeMs = maxKnownAge(rawDataAgeMs, snapshot.snapshotAgeMs)
val freshData = dataAgeMs in 0..freshTimeoutMs
val active = contractOk &&
!blockedByDongleTransport &&
!blockedByMotorTelemetry &&
(available || connected || stateLooksConnected) &&
freshData
val reason = when {
!contractOk -> "BAD_OR_MISSING_CONTRACT_VERSION"
blockedByDongleTransport -> "BLOCKED_BY_DONGLE_TRANSPORT"
blockedByMotorTelemetry -> "BLOCKED_BY_MOTOR_TELEMETRY"
!(available || connected || stateLooksConnected) -> "MOTOR_RUNTIME_STATE_NOT_CONNECTED"
!freshData -> "MOTOR_RUNTIME_STATE_STALE"
else -> "MOTOR_RUNTIME_STATE_ACTIVE"
}
return MotorHomeCardRuntimeStatus(
active = active,
contractOk = contractOk,
available = available,
connected = connected || stateLooksConnected,
state = state.ifBlank { if (connected) "CONNECTED_FLAG" else "" },
blockedByDongleTransport = blockedByDongleTransport,
blockedByMotorTelemetry = blockedByMotorTelemetry,
dataAgeMs = dataAgeMs,
snapshotAgeMs = snapshot.snapshotAgeMs,
reason = reason
)
}
fun latestMotorStateJsonForUi(): String {
val snapshot = snapshot()
val raw = snapshot.rawJson
return if (raw != null) {
val obj = runCatching { JSONObject(raw) }.getOrNull() ?: unavailableJson("getLatestMotorStateJson", snapshot).apply {
put("rawSnippet", raw.take(240))
put("reason", "LAST_RAW_MOTOR_STATE_NOT_JSON")
}
obj.apply {
put("uiPath", "NO_LIVE_BINDER_CALL")
put("source", "motor-telemetry-last-known")
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("effectiveDataAgeMs", snapshot.sample?.let { maxKnownAge(it.dataAgeMs, snapshot.snapshotAgeMs) } ?: JSONObject.NULL)
put("parsedFreshSample", snapshot.sample != null)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
}.toString(2)
} else {
unavailableJson("getLatestMotorStateJson", snapshot).toString(2)
}
}
fun motorControlStateJsonForUi(): String {
val snapshot = snapshot()
val sample = snapshot.sample
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-control-state.ui.v1")
put("uiPath", "NO_LIVE_BINDER_CALL")
put("source", "motor-telemetry-last-known")
put("available", sample != null)
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
put("lastCommandResult", snapshot.lastCommandResult ?: JSONObject.NULL)
put("lastCommandAgeMs", snapshot.lastCommandAgeMs)
if (sample != null) {
put("forceKg", sample.forceKg)
put("forceN", sample.forceN)
put("ropeLengthM", sample.ropeLengthM)
put("currentA", sample.currentA)
put("voltageV", sample.voltageV)
put("tempC", sample.tempC)
put("dataAgeMs", sample.dataAgeMs)
put("effectiveDataAgeMs", maxKnownAge(sample.dataAgeMs, snapshot.snapshotAgeMs))
put("note", "Control-state live query is intentionally disabled on this UI path because the RuntimeHost query can block after idle. Use RuntimeHost cached telemetry or native logs for raw control internals.")
} else {
put("reason", "NO_FRESH_MOTOR_TELEMETRY_SNAPSHOT")
}
}.toString(2)
}
fun trainingModeQueryForUi(mode: Int): String {
val snapshot = snapshot()
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-training-mode-query.ui.v1")
put("uiPath", "NO_LIVE_BINDER_CALL")
put("requestedMode", mode)
put("source", "motor-telemetry-last-known")
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
put("lastCommandResult", snapshot.lastCommandResult ?: JSONObject.NULL)
put("lastCommandAgeMs", snapshot.lastCommandAgeMs)
put("available", snapshot.rawJson != null || snapshot.sample != null)
put("reason", "Live queryMotorTrainingMode is disabled on SDK Panel button path to avoid 2000ms operation timeout after idle.")
}.toString(2)
}
fun disconnectDiagnosisForUi(): String {
val snapshot = snapshot()
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-disconnect-diagnosis.ui.v1")
put("uiPath", "NO_LIVE_BINDER_CALL")
put("source", "motor-telemetry-last-known")
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
put("hasRawMotorState", snapshot.rawJson != null)
put("hasFreshParsedTelemetry", snapshot.sample != null)
put("diagnosis", when {
snapshot.sample != null -> "FRESH_MOTOR_TELEMETRY_AVAILABLE"
snapshot.rawJson != null -> "RAW_MOTOR_STATE_SEEN_BUT_NOT_FRESH_OR_NOT_PARSEABLE"
snapshot.lastError != null -> "MOTOR_STATE_QUERY_ERROR_OR_TIMEOUT_ON_BACKGROUND_POLL"
else -> "NO_MOTOR_TELEMETRY_SNAPSHOT_YET"
})
put("note", "This is a safe UI diagnosis. It does not perform raw live RuntimeHost motor queries.")
}.toString(2)
}
fun dongleSlotEvidenceJson(maxFreshMs: Long = 5_000L): JSONObject? {
val status = homeCardRuntimeStatus(maxFreshMs)
if (!status.contractOk) return null
val hasConnectionEvidence = status.available || status.connected ||
status.state.equals("CONNECTED", ignoreCase = true) ||
status.state.equals("ACTIVE", ignoreCase = true) ||
status.state.equals("RUNNING", ignoreCase = true) ||
status.state.equals("ONLINE", ignoreCase = true)
if (!hasConnectionEvidence) return null
return JSONObject().apply {
put("slot", 3)
put("state", "CONNECTED")
put("connected", true)
put("device", "MOTOR_POWER")
put("dev", "0x31")
put("source", "motor-runtime-state-last-known")
put("inferred", true)
put("available", status.available)
put("runtimeState", status.state)
put("dataAgeMs", status.dataAgeMs)
put("snapshotAgeMs", status.snapshotAgeMs)
put("stale", !status.active)
put("rawSlotReport", "not-queried")
put("detail", "Connected via explicit motor runtime state; raw dongle slot query skipped")
}
}
fun chartSnapshot(freshTimeoutMs: Long = 2_000L): MotorTelemetryChartSnapshot {
val snapshot = snapshot()
val sample = snapshot.sample
val effectiveAgeMs = sample?.let { maxKnownAge(it.dataAgeMs, snapshot.snapshotAgeMs) } ?: Long.MAX_VALUE
val reason = when {
sample == null && snapshot.rawJson == null -> snapshot.lastError ?: "NO_MOTOR_TELEMETRY_SNAPSHOT_YET"
sample == null -> snapshot.lastError ?: "RAW_MOTOR_STATE_NOT_FRESH_OR_NOT_PARSEABLE"
effectiveAgeMs !in 0..freshTimeoutMs -> "MOTOR_TELEMETRY_STALE"
else -> "MOTOR_TELEMETRY_FRESH"
}
return MotorTelemetryChartSnapshot(
sample = if (effectiveAgeMs in 0..freshTimeoutMs) sample else null,
rawJson = snapshot.rawJson,
effectiveAgeMs = effectiveAgeMs,
snapshotAgeMs = snapshot.snapshotAgeMs,
lastError = snapshot.lastError,
reason = reason
)
}
private fun maxKnownAge(vararg ages: Long): Long {
val known = ages.filter { it >= 0L && it != Long.MAX_VALUE }
return if (known.isEmpty()) Long.MAX_VALUE else known.maxOrNull() ?: Long.MAX_VALUE
}
private fun unavailableJson(action: String, snapshot: Snapshot): JSONObject {
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-safe-ui.v1")
put("action", action)
put("uiPath", "NO_LIVE_BINDER_CALL")
put("available", false)
put("reason", "NO_MOTOR_TELEMETRY_SNAPSHOT_YET")
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
}
}
private fun snapshot(): Snapshot = synchronized(lock) {
val now = System.currentTimeMillis()
Snapshot(
rawJson = lastRawJson,
sample = lastSample,
snapshotAgeMs = if (lastUpdateMs > 0L) now - lastUpdateMs else -1L,
lastError = lastError?.let { err ->
val age = if (lastErrorMs > 0L) now - lastErrorMs else -1L
"$err; errorAgeMs=$age"
},
lastCommandResult = lastCommandResult,
lastCommandAgeMs = if (lastCommandAtMs > 0L) now - lastCommandAtMs else -1L
)
}
private data class Snapshot(
val rawJson: String?,
val sample: MotorTelemetrySample?,
val snapshotAgeMs: Long,
val lastError: String?,
val lastCommandResult: String?,
val lastCommandAgeMs: Long
)
}
@@ -0,0 +1,161 @@
package com.kiwii.controlpanel.ui.components
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import java.util.ArrayDeque
import kotlin.math.max
internal class MotorTelemetryScatterChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val samples = ArrayDeque<MotorTelemetrySample>()
private val forcePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(245, 124, 0)
style = Paint.Style.FILL
}
private val lengthPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(25, 118, 210)
style = Paint.Style.FILL
}
private val tempPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(198, 40, 40)
style = Paint.Style.FILL
}
private val axisPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(130, 145, 160)
strokeWidth = 1f.dp()
style = Paint.Style.STROKE
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(70, 130, 145, 160)
strokeWidth = 1f.dp()
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(38, 50, 56)
textSize = 10f.sp()
}
private val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(38, 50, 56)
textSize = 11f.sp()
isFakeBoldText = true
}
private val forceArea = RectF()
private val lengthArea = RectF()
private val tempArea = RectF()
private val visibleWindowMs = 30_000L
fun addSample(sample: MotorTelemetrySample) {
samples.addLast(sample)
val minTime = sample.wallTimeMs - visibleWindowMs
while (!samples.isEmpty() && samples.first.wallTimeMs < minTime) {
samples.removeFirst()
}
invalidate()
}
fun clear() {
samples.clear()
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val w = width.toFloat()
val h = height.toFloat()
if (w <= 0f || h <= 0f) return
val padL = 44f.dp()
val padR = 10f.dp()
val padT = 22f.dp()
val gap = 22f.dp()
val padB = 18f.dp()
val chartH = (h - padT - gap * 2f - padB) / 3f
forceArea.set(padL, padT, w - padR, padT + chartH)
lengthArea.set(padL, padT + chartH + gap, w - padR, padT + chartH + gap + chartH)
tempArea.set(padL, padT + chartH * 2f + gap * 2f, w - padR, padT + chartH * 2f + gap * 2f + chartH)
if (samples.isEmpty()) {
canvas.drawText("Waiting for fresh motor telemetry", padL, h / 2f, titlePaint)
return
}
val now = samples.last.wallTimeMs
val minT = now - visibleWindowMs
val forceMinMax = minMax(samples.map { it.forceKg })
val lengthMinMax = minMax(samples.map { it.ropeLengthM * 100.0 })
val tempMinMax = minMax(samples.map { it.tempC })
drawPanel(canvas, forceArea, "Force kgf", forceMinMax.first, forceMinMax.second)
drawPanel(canvas, lengthArea, "Rope length cm", lengthMinMax.first, lengthMinMax.second)
drawPanel(canvas, tempArea, "Temperature °C", tempMinMax.first, tempMinMax.second)
for (sample in samples) {
val x = xOf(sample.wallTimeMs, minT, now, forceArea)
canvas.drawCircle(x, yOf(sample.forceKg, forceMinMax.first, forceMinMax.second, forceArea), 2.1f.dp(), forcePaint)
canvas.drawCircle(x, yOf(sample.ropeLengthM * 100.0, lengthMinMax.first, lengthMinMax.second, lengthArea), 2.1f.dp(), lengthPaint)
canvas.drawCircle(x, yOf(sample.tempC, tempMinMax.first, tempMinMax.second, tempArea), 2.1f.dp(), tempPaint)
}
canvas.drawText("last 30 s", tempArea.right - 52f.dp(), h - 5f.dp(), textPaint)
}
private fun drawPanel(canvas: Canvas, area: RectF, label: String, minValue: Double, maxValue: Double) {
canvas.drawRect(area, axisPaint)
canvas.drawText(label, area.left, area.top - 6f.dp(), titlePaint)
val mid = area.top + area.height() / 2f
canvas.drawLine(area.left, mid, area.right, mid, gridPaint)
canvas.drawText(formatNumber(maxValue), 4f.dp(), area.top + 10f.dp(), textPaint)
canvas.drawText(formatNumber((minValue + maxValue) / 2.0), 4f.dp(), mid + 4f.dp(), textPaint)
canvas.drawText(formatNumber(minValue), 4f.dp(), area.bottom, textPaint)
}
private fun xOf(timeMs: Long, minT: Long, maxT: Long, area: RectF): Float {
val denom = max(1L, maxT - minT).toDouble()
val f = ((timeMs - minT).toDouble() / denom).coerceIn(0.0, 1.0)
return area.left + (area.width() * f).toFloat()
}
private fun yOf(value: Double, minValue: Double, maxValue: Double, area: RectF): Float {
val denom = max(1e-6, maxValue - minValue)
val f = ((value - minValue) / denom).coerceIn(0.0, 1.0)
return area.bottom - (area.height() * f).toFloat()
}
private fun minMax(values: List<Double>): Pair<Double, Double> {
val finite = values.filter { it.isFinite() }
if (finite.isEmpty()) return 0.0 to 1.0
var mn = finite.minOrNull() ?: 0.0
var mx = finite.maxOrNull() ?: 1.0
if (mx - mn < 1e-6) {
val base = max(1.0, kotlin.math.abs(mx))
mn -= base * 0.05
mx += base * 0.05
}
val span = mx - mn
mn -= span * 0.08
mx += span * 0.08
return mn to mx
}
private fun formatNumber(value: Double): String {
if (!value.isFinite()) return ""
val absValue = kotlin.math.abs(value)
return when {
absValue >= 100.0 -> value.toInt().toString()
absValue >= 10.0 -> String.format("%.1f", value)
else -> String.format("%.2f", value)
}
}
private fun Float.dp(): Float = this * resources.displayMetrics.density
private fun Float.sp(): Float = this * resources.displayMetrics.scaledDensity
}
@@ -2,9 +2,6 @@ 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
@@ -19,7 +16,6 @@ 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
@@ -34,30 +30,10 @@ 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"
val params = entry.params?.let { " [$it]" } ?: "" "$time [$comp] ${entry.action}: ${entry.result?.take(60) ?: ""}"
"$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)
@@ -82,8 +58,4 @@ class SessionLogOps(context: Context) : BaseOps(context) {
return layout return layout
} }
companion object {
private const val TAG_SESSION = "KiwiiSDKPanelSession"
}
} }
@@ -8,8 +8,12 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@@ -26,7 +30,9 @@ import com.kiwii.controlpanel.model.ComponentType
import com.kiwii.controlpanel.model.OperationResult import com.kiwii.controlpanel.model.OperationResult
import com.kiwii.controlpanel.ui.components.* import com.kiwii.controlpanel.ui.components.*
import com.kiwii.controlpanel.util.JsonFormatter import com.kiwii.controlpanel.util.JsonFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -41,7 +47,14 @@ class ComponentDetailFragment : Fragment() {
private lateinit var stateAdapter: StatAdapter private lateinit var stateAdapter: StatAdapter
private lateinit var logAdapter: LogAdapter private lateinit var logAdapter: LogAdapter
private var latestStateText: String = "No state data" private var latestStateText: String = "No state data"
private var latestResultText: String = "" private var latestResultText: String = """
State Query Results will appear here.
For Motor, use getLatestMotorStateJson / getMotorDisconnectDiagnosisJson, or wait for auto disconnect diagnosis.
Left column is scrollable; scroll down to see the full result and Session Log.
""".trimIndent()
private var operationResultTextView: TextView? = null
private var lastAutoDisconnectDiagnosisTimestamp: Long = 0L
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -63,6 +76,8 @@ class ComponentDetailFragment : Fragment() {
setupMenu() setupMenu()
setupStateAndLog() setupStateAndLog()
ensureStateQueryResultsPanel()
updateOperationResultText(latestResultText)
loadOpsView() loadOpsView()
observeViewModel() observeViewModel()
} }
@@ -106,6 +121,21 @@ class ComponentDetailFragment : Fragment() {
binding.rvLogEntries.adapter = logAdapter binding.rvLogEntries.adapter = logAdapter
} }
private fun ensureStateQueryResultsPanel() {
// v17 uses a static XML panel inside the left ScrollView. The left column is now
// scrollable like the right Controls column, and the State Query Results area has
// a large reserved height so long JSON / disconnect diagnosis remains visible.
operationResultTextView = binding.tvOperationResult
}
private fun updateOperationResultText(text: String) {
operationResultTextView?.text = text
}
private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density + 0.5f).toInt()
}
private fun loadOpsView() { private fun loadOpsView() {
val opsView = when (componentType) { val opsView = when (componentType) {
ComponentType.RUNTIME_HOST_STATUS -> RuntimeHostStatusOps(requireContext(), viewModel) ComponentType.RUNTIME_HOST_STATUS -> RuntimeHostStatusOps(requireContext(), viewModel)
@@ -173,9 +203,10 @@ class ComponentDetailFragment : Fragment() {
latestResultText = "No Data" latestResultText = "No Data"
} }
null -> { null -> {
latestResultText = "" // Keep the initial guidance text visible until the first explicit State Query result arrives.
} }
} }
updateOperationResultText(latestResultText)
} }
} }
@@ -195,9 +226,34 @@ class ComponentDetailFragment : Fragment() {
binding.rvLogEntries.scrollToPosition(visibleEntries.size - 1) binding.rvLogEntries.scrollToPosition(visibleEntries.size - 1)
} }
} }
maybeAutoShowMotorDisconnectDiagnosis(visibleEntries)
} }
} }
private suspend fun maybeAutoShowMotorDisconnectDiagnosis(entries: List<LogEntry>) {
if (componentType != ComponentType.MOTOR) return
val event = entries.lastOrNull { entry ->
entry.component == ComponentType.MOTOR &&
entry.action == "availability_change" &&
(entry.result ?: "").contains("ACTIVE -> GREY")
} ?: return
if (event.timestamp <= lastAutoDisconnectDiagnosisTimestamp) return
lastAutoDisconnectDiagnosisTimestamp = event.timestamp
val diagnosis = withContext(Dispatchers.IO) {
viewModel.bridgeRepo.getMotorDisconnectDiagnosisJson()
}
latestResultText = JsonFormatter.format(diagnosis)
updateOperationResultText(latestResultText)
SessionLogger.log(LogEntry(
timestamp = System.currentTimeMillis(),
component = ComponentType.MOTOR,
action = "autoDisconnectDiagnosis",
params = "trigger=${event.result}",
result = diagnosis
))
}
private fun formatResultData(data: Any?): String { private fun formatResultData(data: Any?): String {
return when (data) { return when (data) {
is String -> JsonFormatter.format(data) is String -> JsonFormatter.format(data)
@@ -220,6 +276,12 @@ class ComponentDetailFragment : Fragment() {
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
operationResultTextView = null
_binding = null _binding = null
} }
companion object {
private const val STATE_QUERY_PANEL_TAG = "state_query_results_panel_v15"
private const val STATE_QUERY_TEXT_TAG = "state_query_results_text_v15"
}
} }
@@ -1,6 +1,5 @@
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
@@ -12,7 +11,6 @@ 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
@@ -21,38 +19,21 @@ 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?> = combine( val stateSnapshot: StateFlow<StateSnapshot?> = _componentType
_componentType, .combine(statePoller.states) { type, map -> map[type] }
statePoller.states, .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
_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
@@ -61,7 +42,7 @@ class ComponentDetailViewModel : ViewModel() {
statePoller.startPolling() statePoller.startPolling()
viewModelScope.launch { viewModelScope.launch {
while (isActive) { while (isActive) {
refreshVisibleLogs() _logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value)
delay(1000) delay(1000)
} }
} }
@@ -69,8 +50,6 @@ 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(
@@ -80,21 +59,9 @@ class ComponentDetailViewModel : ViewModel() {
block: () -> T block: () -> T
) { ) {
viewModelScope.launch { viewModelScope.launch {
val startedAt = System.currentTimeMillis() val result = bridgeRepo.callAsync(block)
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}"
@@ -102,69 +69,20 @@ 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 = now, timestamp = System.currentTimeMillis(),
component = componentType, component = componentType,
action = "RX:$actionName", action = actionName,
params = params, params = params,
result = resultStr result = resultStr
)) ))
refreshVisibleLogs()
}
}
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) _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,13 +10,6 @@ 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,6 +1,5 @@
package com.kiwii.controlpanel.ui.detail package com.kiwii.controlpanel.ui.detail
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
@@ -15,36 +14,28 @@ 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 emptyList() return items
} }
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 (raw) { val item = when {
is JSONObject -> StatItem( raw 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()
@@ -56,186 +47,6 @@ 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
@@ -248,9 +59,4 @@ 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 }
}
} }
@@ -7,13 +7,20 @@
android:background="@drawable/bg_rhs_card" android:background="@drawable/bg_rhs_card"
android:padding="8dp"> android:padding="8dp">
<!-- Left: State + Log --> <!-- Left: scrollable State + State Query Results + Session Log -->
<LinearLayout <ScrollView
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="45" android:layout_weight="45"
android:orientation="vertical" android:layout_marginEnd="6dp"
android:layout_marginEnd="6dp"> android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:id="@+id/left_scroll_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- State Card --> <!-- State Card -->
<LinearLayout <LinearLayout
@@ -56,16 +63,57 @@
android:layout_marginTop="4dp" /> android:layout_marginTop="4dp" />
</LinearLayout> </LinearLayout>
<!-- Session Log --> <!-- State Query Results / Disconnect Diagnosis -->
<LinearLayout <LinearLayout
android:id="@+id/state_query_results_panel"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="420dp"
android:layout_weight="1"
android:orientation="vertical" android:orientation="vertical"
android:background="@drawable/bg_rhs_status_inner" android:background="@drawable/bg_rhs_status_inner"
android:padding="6dp" android:padding="6dp"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:minHeight="0dp"> android:minHeight="360dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="State Query Results / Disconnect Diagnosis"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="@color/rhs_title"
android:layout_marginBottom="4dp" />
<ScrollView
android:id="@+id/operation_result_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@drawable/bg_rhs_log_area"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:padding="6dp">
<TextView
android:id="@+id/tv_operation_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="State Query Results will appear here. For Motor, use getLatestMotorStateJson / getMotorDisconnectDiagnosisJson, or wait for auto disconnect diagnosis."
android:textSize="10sp"
android:fontFamily="monospace"
android:textColor="@color/rhs_stat_value"
android:textIsSelectable="true" />
</ScrollView>
</LinearLayout>
<!-- Session Log -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="260dp"
android:orientation="vertical"
android:background="@drawable/bg_rhs_status_inner"
android:padding="6dp"
android:layout_marginTop="6dp"
android:minHeight="180dp">
<RelativeLayout <RelativeLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -88,9 +136,10 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:overScrollMode="never" /> android:overScrollMode="ifContentScrolls" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView>
<!-- Right: Controls --> <!-- Right: Controls -->
<LinearLayout <LinearLayout