Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4375e01b08 | |||
| bc6dd50fc4 |
@@ -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")
|
|
||||||
val running = obj.optBoolean("running", false) || obj.optString("state", "") == "RUNNING"
|
|
||||||
val imx415Open = obj.optBoolean("imx415OpenSucceeded", false) || pipeline?.optBoolean("imx415OpenSucceeded", false) == true
|
|
||||||
val rga = obj.optBoolean("rgaEnabled", false) || pipeline?.optBoolean("rgaEnabled", false) == true
|
|
||||||
val rknn = obj.optBoolean("rknnYoloEnabled", false) || pipeline?.optBoolean("rknnYoloEnabled", false) == true
|
|
||||||
val videoPose3D = obj.optBoolean("videoPose3DEnabled", false) || pipeline?.optBoolean("videoPose3DEnabled", false) == true
|
|
||||||
val pose2DAvailable = pose2D?.optBoolean("available", false) == true
|
|
||||||
val pose3DAvailable = pose3D?.optBoolean("available", false) == true
|
|
||||||
if (running || imx415Open || (rga && rknn) || videoPose3D || pose2DAvailable || pose3DAvailable) {
|
|
||||||
ComponentState.ACTIVE
|
|
||||||
} else {
|
|
||||||
ComponentState.GREY
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.w(TAG, "checkCamera exception: ${t.message}")
|
|
||||||
ComponentState.GREY
|
ComponentState.GREY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkHandle(signal: HandleSignal, progressAgeMs: Long): ComponentState {
|
private fun checkHandle(side: String): ComponentState {
|
||||||
if (signal.side.isBlank()) return ComponentState.GREY
|
return try {
|
||||||
val liveFlags = signal.available || signal.connected || signal.streaming
|
val json = repo.getHandleStateJson()
|
||||||
val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS
|
val obj = JSONObject(json)
|
||||||
val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS
|
val sideObj = obj.optJSONObject(side) ?: return ComponentState.GREY
|
||||||
val hasPayload = signal.sequenceId > 0L && signal.queueSize > 0
|
// handle-state.v1: check available AND connected
|
||||||
val streamingActive = liveFlags && hasPayload && (dataFresh || sequenceProgressFresh)
|
val available = sideObj.optBoolean("available", false)
|
||||||
val slotConnectedFresh = signal.slot.freshConnected
|
val connected = sideObj.optBoolean("connected", false)
|
||||||
// Phase4B-v11.19: home card should distinguish BLE connection from IMU stream.
|
if (available || connected) ComponentState.ACTIVE else ComponentState.GREY
|
||||||
// A freshly connected Dongle slot is valid "connected" evidence even before
|
} catch (_: Exception) {
|
||||||
// handle IMU frames arrive; stale last-known payload still cannot keep it green.
|
ComponentState.GREY
|
||||||
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 {
|
private fun checkBalanceBoard(): ComponentState {
|
||||||
val rightFresh = isHandleFreshForDongle(handles.right, handles.rightProgressAgeMs)
|
// BalanceBoard 没有专用 API,暂时用 debugSnapshot 判断
|
||||||
val leftFresh = isHandleFreshForDongle(handles.left, handles.leftProgressAgeMs)
|
// TODO: 确认 RuntimeHost debug snapshot 中 balance board 状态字段
|
||||||
val dongleProgressFresh = handles.dongleProgressAgeMs <= DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS
|
return try {
|
||||||
val recentHandleProgress = dongleProgressFresh && (rightFresh || leftFresh)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase4B-v11.17: Dongle card means SmartBase USB/CDC transport is actually alive.
|
private fun checkDongle(): ComponentState {
|
||||||
// Do not mark it ACTIVE merely because RuntimeHost is bound, a placeholder exists,
|
val state = computeDongleState()
|
||||||
// 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
|
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
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isHandleFreshForDongle(signal: HandleSignal, progressAgeMs: Long): Boolean {
|
private fun computeDongleState(): ComponentState {
|
||||||
val liveFlags = signal.available || signal.connected || signal.streaming
|
return try {
|
||||||
val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS
|
val json = repo.getDongleStateJson()
|
||||||
val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS
|
if (isUnavailable(json)) return ComponentState.GREY
|
||||||
return signal.slot.freshConnected || (liveFlags && signal.sequenceId > 0L && signal.queueSize > 0 && (dataFresh || sequenceProgressFresh))
|
val obj = JSONObject(json)
|
||||||
|
val transport = obj.optJSONObject("transport") ?: return ComponentState.GREY
|
||||||
|
|
||||||
|
val active = obj.optBoolean("active", false) || obj.optBoolean("available", false) || obj.optBoolean("connected", false)
|
||||||
|
val transportActive = transport.optBoolean("active", false) || transport.optBoolean("connected", false)
|
||||||
|
val usbBridgeRegistered = transport.optBoolean("usbBridgeRegistered", false)
|
||||||
|
val readLoopRunning = transport.optBoolean("readLoopRunning", false)
|
||||||
|
val usbPresent = transport.optBoolean("usbPresent", false)
|
||||||
|
val state = transport.optString("state", "")
|
||||||
|
|
||||||
|
// Dongle 首页卡片表达“USB transport 已经被 RuntimeHost 打开并注册”,
|
||||||
|
// 不要求 lastUsbInAgeMs < 3s。是否有上行数据滞后由 detail page 的 dataStale/lastUsbInAgeMs 显示。
|
||||||
|
if ((active || transportActive || state == "CONNECTED") &&
|
||||||
|
usbPresent &&
|
||||||
|
usbBridgeRegistered &&
|
||||||
|
readLoopRunning
|
||||||
|
) {
|
||||||
|
ComponentState.ACTIVE
|
||||||
|
} else {
|
||||||
|
ComponentState.GREY
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ComponentState.GREY
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(createParameterActionRow(queryModeAction, axisInput))
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-99
@@ -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)
|
||||||
if (sample == null) {
|
mainHandler.post {
|
||||||
val reason = "${snapshot.reason}; effectiveAgeMs=${printAge(snapshot.effectiveAgeMs)}; snapshotAgeMs=${printAge(snapshot.snapshotAgeMs)}"
|
if (sample == null) {
|
||||||
logPollNoSample(reason)
|
setUnavailable("No fresh motor telemetry")
|
||||||
postUnavailable(reason)
|
} else {
|
||||||
} else {
|
updateSample(sample)
|
||||||
if (sample.wallTimeMs == lastRenderedWallTimeMs) {
|
|
||||||
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"
|
||||||
|
chartView.addSample(sample)
|
||||||
if (sample.wallTimeMs != lastRenderedWallTimeMs) {
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+161
@@ -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(
|
_logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value)
|
||||||
actionName: String,
|
|
||||||
block: () -> T
|
|
||||||
): OperationResult<T> = withContext(Dispatchers.IO) {
|
|
||||||
val future = operationExecutor.submit<OperationResult<T>> {
|
|
||||||
try {
|
|
||||||
val result = block()
|
|
||||||
if (result == null || (result is String && result.isBlank())) {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
OperationResult.NoData as OperationResult<T>
|
|
||||||
} else {
|
|
||||||
OperationResult.Success(result)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
OperationResult.Error(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
future.get(OPERATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
|
||||||
} catch (e: TimeoutException) {
|
|
||||||
future.cancel(true)
|
|
||||||
OperationResult.Error(Exception("$actionName timed out after ${OPERATION_TIMEOUT_MS}ms"))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
OperationResult.Error(Exception("$actionName failed: ${e.message}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshVisibleLogs() {
|
|
||||||
_logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
statePoller.stopPolling()
|
statePoller.stopPolling()
|
||||||
operationExecutor.shutdownNow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val OPERATION_TIMEOUT_MS = 2000L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,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,90 +7,139 @@
|
|||||||
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">
|
||||||
|
|
||||||
<!-- State Card -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/left_scroll_content"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical">
|
||||||
android:background="@drawable/bg_rhs_status_inner"
|
|
||||||
android:padding="8dp">
|
|
||||||
|
|
||||||
<RelativeLayout
|
<!-- State Card -->
|
||||||
android:layout_width="match_parent"
|
<LinearLayout
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_state_title"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_alignParentStart="true"
|
|
||||||
android:layout_centerVertical="true"
|
|
||||||
android:text="State"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@color/rhs_title" />
|
|
||||||
</RelativeLayout>
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/rv_state_stats"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/bg_rhs_status_inner"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_state_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:text="State"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/rhs_title" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rv_state_stats"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:overScrollMode="never" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_state_updated_at"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/rhs_timestamp"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- State Query Results / Disconnect Diagnosis -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/state_query_results_panel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="420dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/bg_rhs_status_inner"
|
||||||
|
android:padding="6dp"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:overScrollMode="never" />
|
android:minHeight="360dp">
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_state_updated_at"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="9sp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:textColor="@color/rhs_timestamp"
|
|
||||||
android:layout_marginTop="4dp" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- Session Log -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/bg_rhs_status_inner"
|
|
||||||
android:padding="6dp"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:minHeight="0dp">
|
|
||||||
|
|
||||||
<RelativeLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="4dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentStart="true"
|
android:text="State Query Results / Disconnect Diagnosis"
|
||||||
android:layout_centerVertical="true"
|
|
||||||
android:text="Session Log"
|
|
||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/rhs_title" />
|
android:textColor="@color/rhs_title"
|
||||||
</RelativeLayout>
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<ScrollView
|
||||||
android:id="@+id/rv_log_entries"
|
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_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="260dp"
|
||||||
android:layout_weight="1"
|
android:orientation="vertical"
|
||||||
android:overScrollMode="never" />
|
android:background="@drawable/bg_rhs_status_inner"
|
||||||
|
android:padding="6dp"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:minHeight="180dp">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="4dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:text="Session Log"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/rhs_title" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rv_log_entries"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="ifContentScrolls" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</ScrollView>
|
||||||
|
|
||||||
<!-- Right: Controls -->
|
<!-- Right: Controls -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|||||||
Reference in New Issue
Block a user