Fix dongle state, slot evidence, and motor telemetry panel
This commit is contained in:
@@ -2,22 +2,52 @@ 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.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
class BridgeRepository {
|
class BridgeRepository {
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val HANDLE_CACHE_MAX_AGE_MS = 5000L
|
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
|
@Volatile
|
||||||
private var cachedHandleStateJson: String = ""
|
private var cachedHandleStateJson: String = ""
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var cachedHandleStateAtMs: Long = 0L
|
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)
|
||||||
@@ -142,11 +172,240 @@ class BridgeRepository {
|
|||||||
|
|
||||||
fun getHandleStateJson(): String {
|
fun getHandleStateJson(): String {
|
||||||
val json = 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\"")) {
|
if (json.isNotBlank() && !json.contains("\"error\"")) {
|
||||||
cachedHandleStateJson = json
|
cachedHandleStateJson = json
|
||||||
cachedHandleStateAtMs = System.currentTimeMillis()
|
cachedHandleStateAtMs = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
return json
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = "]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -188,7 +447,7 @@ class BridgeRepository {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase4B-v11.6 SDK Panel no-blocking detail-state view for Dongle.
|
* Phase4B-v11.9 SDK Panel no-blocking detail-state view for Dongle.
|
||||||
*
|
*
|
||||||
* Important design decision: this UI path must NOT call raw getDongleStateJson().
|
* Important design decision: this UI path must NOT call raw getDongleStateJson().
|
||||||
* On current RuntimeHost builds that raw dongle query can block behind debug/global
|
* On current RuntimeHost builds that raw dongle query can block behind debug/global
|
||||||
@@ -202,26 +461,26 @@ class BridgeRepository {
|
|||||||
* diagnostic view and must remain responsive.
|
* diagnostic view and must remain responsive.
|
||||||
*/
|
*/
|
||||||
fun getDongleDetailStateJson(): String {
|
fun getDongleDetailStateJson(): String {
|
||||||
// Phase4B-v11.6: readDongleState and Dongle detail State must be zero-blocking.
|
// Phase4B-v11.17: readDongleState remains zero-blocking, but no longer uses a
|
||||||
// Do not call getDongleStateJson() or getHandleStateJson() here. Both are binder calls
|
// fake NOT_QUERIED placeholder as the primary state. The home-card poller keeps a
|
||||||
// and either one can become slow after long idle periods. Use the latest cached
|
// cached RuntimeHost dongle transport snapshot; this method displays that cached
|
||||||
// HandleState populated by the home-card poller; if the cache is stale, return a
|
// raw state plus inferred slot evidence from cached Handle/Motor snapshots.
|
||||||
// responsive safe state instead of timing out the UI.
|
requestDongleStateRefreshAsync("read-dongle-state-click")
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val cacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
|
val handleCacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
|
||||||
val handleRaw = if (cachedHandleStateJson.isNotBlank() && cacheAgeMs <= HANDLE_CACHE_MAX_AGE_MS) {
|
val handleRaw = if (cachedHandleStateJson.isNotBlank()) {
|
||||||
cachedHandleStateJson
|
cachedHandleStateJson
|
||||||
} else {
|
} else {
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("error", "no-fresh-cached-handle-state")
|
.put("error", "no-cached-handle-state")
|
||||||
.put("handleCacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs)
|
.put("handleCacheAgeMs", if (handleCacheAgeMs == Long.MAX_VALUE) -1L else handleCacheAgeMs)
|
||||||
.put("detail", "Safe Dongle detail path uses cached HandleState only; live binder query skipped to avoid idle timeout.")
|
.put("detail", "Safe Dongle detail path uses last-known HandleState only; live binder query skipped to avoid idle timeout.")
|
||||||
.toString()
|
.toString()
|
||||||
}
|
}
|
||||||
return buildDongleDetailStateJson(
|
return buildDongleDetailStateJson(
|
||||||
buildSafeDonglePlaceholderJson(),
|
getCachedRawDongleStateJsonOrUnavailable(),
|
||||||
handleRaw,
|
handleRaw,
|
||||||
if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs
|
if (handleCacheAgeMs == Long.MAX_VALUE) -1L else handleCacheAgeMs
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +488,7 @@ class BridgeRepository {
|
|||||||
|
|
||||||
private fun buildSafeDonglePlaceholderJson(): String {
|
private fun buildSafeDonglePlaceholderJson(): String {
|
||||||
return JSONObject()
|
return JSONObject()
|
||||||
.put("contractVersion", "kiwii.sdk-panel.dongle-safe-placeholder.v11.6")
|
.put("contractVersion", "kiwii.sdk-panel.dongle-safe-placeholder.v11.9")
|
||||||
.put("source", "SDK_Panel.non-blocking-dongle-detail")
|
.put("source", "SDK_Panel.non-blocking-dongle-detail")
|
||||||
.put("rawDongleQuery", "disabled-on-detail-ui-path")
|
.put("rawDongleQuery", "disabled-on-detail-ui-path")
|
||||||
.put("transport", JSONObject()
|
.put("transport", JSONObject()
|
||||||
@@ -243,29 +502,31 @@ class BridgeRepository {
|
|||||||
val dongle = parseJsonOrEmpty(dongleRaw)
|
val dongle = parseJsonOrEmpty(dongleRaw)
|
||||||
val handle = parseJsonOrEmpty(handleRaw)
|
val handle = parseJsonOrEmpty(handleRaw)
|
||||||
val out = JSONObject()
|
val out = JSONObject()
|
||||||
out.put("contractVersion", "kiwii.sdk-panel.dongle-detail-state.v11.6")
|
out.put("contractVersion", "kiwii.sdk-panel.dongle-detail-state.v11.17")
|
||||||
out.put("source", "SDK_Panel.safe-handle-state-dongle-detail")
|
out.put("source", "SDK_Panel.cached-dongle-and-handle-state-detail")
|
||||||
out.put("diagnosticSemantics", "operational-state-first; no-live-binder-call-on-dongle-detail; raw-slot-report-secondary")
|
out.put("diagnosticSemantics", "transport-state-first; no-live-binder-call-on-dongle-detail; inferred-slot-report-secondary")
|
||||||
out.put("handleCacheAgeMs", handleCacheAgeMs)
|
out.put("handleCacheAgeMs", handleCacheAgeMs)
|
||||||
out.put("handleCacheFresh", handleCacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS)
|
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("generatedAtMs", System.currentTimeMillis())
|
||||||
out.put("dongle", dongle)
|
out.put("dongle", dongle)
|
||||||
out.put("handleState", handle)
|
out.put("handleState", handle)
|
||||||
|
|
||||||
val transport = dongle.optJSONObject("transport") ?: JSONObject()
|
val transport = dongle.optJSONObject("transport") ?: JSONObject()
|
||||||
out.put("transport", transport)
|
out.put("transport", transport)
|
||||||
out.put("slots", buildSdkPanelSlots(dongle, handle))
|
out.put("slots", buildSdkPanelSlots(dongle, handle, handleCacheAgeMs))
|
||||||
|
|
||||||
val evidenceNote = buildSlotEvidenceNote(dongle, handle)
|
val evidenceNote = buildSlotEvidenceNote(dongle, handle, handleCacheAgeMs)
|
||||||
if (evidenceNote.isNotBlank()) out.put("slotEvidenceNote", evidenceNote)
|
if (evidenceNote.isNotBlank()) out.put("slotEvidenceNote", evidenceNote)
|
||||||
return out.toString()
|
return out.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSdkPanelSlots(dongle: JSONObject, handle: JSONObject): JSONArray {
|
private fun buildSdkPanelSlots(dongle: JSONObject, handle: JSONObject, handleCacheAgeMs: Long): JSONArray {
|
||||||
val sourceSlots = firstSlotArray(dongle)
|
val sourceSlots = firstSlotArray(dongle)
|
||||||
val slots = JSONArray()
|
val slots = JSONArray()
|
||||||
val left = handle.optJSONObject("left") ?: JSONObject()
|
val left = handle.optJSONObject("left") ?: JSONObject()
|
||||||
val right = handle.optJSONObject("right") ?: JSONObject()
|
val right = handle.optJSONObject("right") ?: JSONObject()
|
||||||
|
val motorSlotEvidence = MotorTelemetrySnapshotCache.dongleSlotEvidenceJson()
|
||||||
|
|
||||||
for (slotIndex in 0..3) {
|
for (slotIndex in 0..3) {
|
||||||
val reported = findSlotObject(slotIndex, sourceSlots)
|
val reported = findSlotObject(slotIndex, sourceSlots)
|
||||||
@@ -273,21 +534,29 @@ class BridgeRepository {
|
|||||||
slot.put("slot", slotIndex)
|
slot.put("slot", slotIndex)
|
||||||
|
|
||||||
val inferredHandle = when (slotIndex) {
|
val inferredHandle = when (slotIndex) {
|
||||||
0 -> if (isFreshHandle(left)) "LEFT_HANDLE" else ""
|
0 -> if (hasFreshHandleSlotEvidence(left, handleCacheAgeMs)) "LEFT_HANDLE" else ""
|
||||||
1 -> if (isFreshHandle(right)) "RIGHT_HANDLE" else ""
|
1 -> if (hasFreshHandleSlotEvidence(right, handleCacheAgeMs)) "RIGHT_HANDLE" else ""
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inferredHandle.isNotBlank()) {
|
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
|
val handleObj = if (slotIndex == 0) left else right
|
||||||
slot.put("state", "CONNECTED")
|
slot.put("state", "CONNECTED")
|
||||||
slot.put("connected", true)
|
slot.put("connected", true)
|
||||||
slot.put("device", inferredHandle)
|
slot.put("device", inferredHandle)
|
||||||
slot.put("source", "handle-state-inferred")
|
slot.put("source", "handle-state-fresh-inferred")
|
||||||
slot.put("inferred", true)
|
slot.put("inferred", true)
|
||||||
slot.put("sequenceId", handleObj.optLong("sequenceId", handleObj.optLong("seq", 0L)))
|
slot.put("sequenceId", handleObj.optLong("sequenceId", handleObj.optLong("seq", 0L)))
|
||||||
slot.put("dataAgeMs", handleObj.optLong("dataAgeMs", -1L))
|
slot.put("dataAgeMs", handleObj.optLong("dataAgeMs", -1L))
|
||||||
slot.put("queueSize", handleObj.optInt("queueSize", 0))
|
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 reportedState = reported?.optString("state", reported.optString("status", "")) ?: ""
|
||||||
val rawSlotReport = when {
|
val rawSlotReport = when {
|
||||||
reported == null -> if (dongle.optString("rawDongleQuery", "") == "disabled-on-detail-ui-path") "not-queried" else "missing"
|
reported == null -> if (dongle.optString("rawDongleQuery", "") == "disabled-on-detail-ui-path") "not-queried" else "missing"
|
||||||
@@ -297,11 +566,11 @@ class BridgeRepository {
|
|||||||
}
|
}
|
||||||
slot.put("rawSlotReport", rawSlotReport)
|
slot.put("rawSlotReport", rawSlotReport)
|
||||||
if (reportedState.isNotBlank() && !reportedState.equals("CONNECTED", ignoreCase = true)) {
|
if (reportedState.isNotBlank() && !reportedState.equals("CONNECTED", ignoreCase = true)) {
|
||||||
slot.put("detail", "Connected via handle-state; rawSlot=$rawSlotReport")
|
slot.put("detail", "Connected via fresh handle-state; rawSlot=$rawSlotReport")
|
||||||
} else if (reported == null) {
|
} else if (reported == null) {
|
||||||
slot.put("detail", "Connected via handle-state; rawSlot=$rawSlotReport")
|
slot.put("detail", "Connected via fresh handle-state; rawSlot=$rawSlotReport")
|
||||||
} else {
|
} else {
|
||||||
slot.put("detail", "Connected via handle-state")
|
slot.put("detail", "Connected via fresh handle-state")
|
||||||
}
|
}
|
||||||
} else if (reported != null) {
|
} else if (reported != null) {
|
||||||
val connected = reported.optBooleanFlexible("connected") ||
|
val connected = reported.optBooleanFlexible("connected") ||
|
||||||
@@ -338,21 +607,20 @@ class BridgeRepository {
|
|||||||
return slots
|
return slots
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSlotEvidenceNote(dongle: JSONObject, handle: JSONObject): String {
|
private fun buildSlotEvidenceNote(dongle: JSONObject, handle: JSONObject, handleCacheAgeMs: Long): String {
|
||||||
val sourceSlots = firstSlotArray(dongle)
|
val sourceSlots = firstSlotArray(dongle)
|
||||||
val right = handle.optJSONObject("right") ?: JSONObject()
|
val right = handle.optJSONObject("right") ?: JSONObject()
|
||||||
val rightFresh = isFreshHandle(right)
|
val rightFresh = hasFreshHandleSlotEvidence(right, handleCacheAgeMs)
|
||||||
val reportedSlot1 = findSlotObject(1, sourceSlots)
|
val reportedSlot1 = findSlotObject(1, sourceSlots)
|
||||||
val reportedSlot1Connected = reportedSlot1?.let {
|
val reportedSlot1Connected = reportedSlot1?.let {
|
||||||
it.optBooleanFlexible("connected") || it.optBooleanFlexible("available") || it.optBooleanFlexible("active") ||
|
it.optBooleanFlexible("connected") || it.optBooleanFlexible("available") || it.optBooleanFlexible("active") ||
|
||||||
it.optString("state", "").equals("CONNECTED", ignoreCase = true) ||
|
it.optString("state", "").equals("CONNECTED", ignoreCase = true) ||
|
||||||
it.optString("status", "").equals("CONNECTED", ignoreCase = true)
|
it.optString("status", "").equals("CONNECTED", ignoreCase = true)
|
||||||
} ?: false
|
} ?: false
|
||||||
return if (rightFresh && !reportedSlot1Connected) {
|
val notes = mutableListOf<String>()
|
||||||
"Slot 1 inferred from cached Right Handle data."
|
if (rightFresh && !reportedSlot1Connected) notes += "Slot 1 inferred from cached Right Handle data."
|
||||||
} else {
|
if (MotorTelemetrySnapshotCache.dongleSlotEvidenceJson() != null) notes += "Slot 3 inferred from explicit Motor runtime state."
|
||||||
""
|
return notes.joinToString(" ")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun firstSlotArray(obj: JSONObject): JSONArray? {
|
private fun firstSlotArray(obj: JSONObject): JSONArray? {
|
||||||
@@ -374,15 +642,16 @@ class BridgeRepository {
|
|||||||
return slots.optJSONObject(slotIndex)
|
return slots.optJSONObject(slotIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isFreshHandle(handle: JSONObject): Boolean {
|
private fun hasFreshHandleSlotEvidence(handle: JSONObject, handleCacheAgeMs: Long): Boolean {
|
||||||
val available = handle.optBooleanFlexible("available")
|
val available = handle.optBooleanFlexible("available")
|
||||||
val connected = handle.optBooleanFlexible("connected")
|
val connected = handle.optBooleanFlexible("connected")
|
||||||
val streaming = handle.optBooleanFlexible("streaming")
|
val streaming = handle.optBooleanFlexible("streaming")
|
||||||
val seq = handle.optLong("sequenceId", handle.optLong("seq", 0L))
|
val seq = handle.optLong("sequenceId", handle.optLong("seq", 0L))
|
||||||
val queueSize = handle.optInt("queueSize", 0)
|
val queueSize = handle.optInt("queueSize", 0)
|
||||||
val age = handle.optLong("dataAgeMs", -1L)
|
val dataAgeMs = handle.optLong("dataAgeMs", Long.MAX_VALUE)
|
||||||
val ageFresh = age in 0..2500
|
val cacheFresh = handleCacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS
|
||||||
return (available || connected || streaming) && (ageFresh || (seq > 0L && queueSize > 0))
|
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 {
|
private fun parseJsonOrEmpty(raw: String): JSONObject {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -30,7 +31,23 @@ class ComponentRegistry(
|
|||||||
const val HANDLE_DATA_AGE_FRESH_TIMEOUT_MS = 1500L
|
const val HANDLE_DATA_AGE_FRESH_TIMEOUT_MS = 1500L
|
||||||
const val HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L
|
const val HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L
|
||||||
const val DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L
|
const val DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L
|
||||||
const val DONGLE_RULE = "RECENT_HANDLE_PROGRESS_NO_RAW_DONGLE_QUERY"
|
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(
|
||||||
|
val slot: Int = -1,
|
||||||
|
val dev: Int = 0,
|
||||||
|
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(
|
private data class HandleSignal(
|
||||||
@@ -42,7 +59,8 @@ class ComponentRegistry(
|
|||||||
val dataAgeMs: Long = -1L,
|
val dataAgeMs: Long = -1L,
|
||||||
val queueSize: Int = 0,
|
val queueSize: Int = 0,
|
||||||
val health: String = "",
|
val health: String = "",
|
||||||
val source: String = ""
|
val source: String = "",
|
||||||
|
val slot: SlotSignal = SlotSignal()
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class DongleSignal(
|
private data class DongleSignal(
|
||||||
@@ -51,6 +69,10 @@ class ComponentRegistry(
|
|||||||
val usbPresent: Boolean = false,
|
val usbPresent: Boolean = false,
|
||||||
val usbBridgeRegistered: Boolean = false,
|
val usbBridgeRegistered: Boolean = false,
|
||||||
val readLoopRunning: 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 state: String = "",
|
||||||
val source: String = "",
|
val source: String = "",
|
||||||
val evidence: String = ""
|
val evidence: String = ""
|
||||||
@@ -63,14 +85,19 @@ class ComponentRegistry(
|
|||||||
val dongleStateLocated: Boolean = false,
|
val dongleStateLocated: Boolean = false,
|
||||||
val observationBufferLocated: Boolean = false,
|
val observationBufferLocated: Boolean = false,
|
||||||
val dongle: DongleSignal = DongleSignal(),
|
val dongle: DongleSignal = DongleSignal(),
|
||||||
|
val motorSlot: SlotSignal = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER"),
|
||||||
val leftProgressAgeMs: Long = Long.MAX_VALUE,
|
val leftProgressAgeMs: Long = Long.MAX_VALUE,
|
||||||
val rightProgressAgeMs: Long = Long.MAX_VALUE,
|
val rightProgressAgeMs: Long = Long.MAX_VALUE,
|
||||||
val dongleProgressAgeMs: 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 lastLeftSequenceId: Long = 0L
|
||||||
private var lastRightSequenceId: Long = 0L
|
private var lastRightSequenceId: Long = 0L
|
||||||
private var lastLeftSequenceProgressWallMs: Long = 0L
|
private var lastLeftSequenceProgressWallMs: Long = 0L
|
||||||
@@ -81,15 +108,18 @@ class ComponentRegistry(
|
|||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val bound = repo.isBound()
|
val bound = repo.isBound()
|
||||||
val handleJson = if (bound) runCatching { repo.getHandleStateJson() }.getOrDefault("{\"error\":\"handle_state_exception\"}") else "{\"error\":\"NOT_BOUND\"}"
|
if (bound) {
|
||||||
val runtimeStats = if (bound) runCatching { repo.getLatestRuntimeStats() }.getOrDefault(FloatArray(0)) else FloatArray(0)
|
repo.requestHandleStateRefreshAsync("availability-poll")
|
||||||
val cameraJson = if (bound) runCatching { repo.getCameraStateJson() }.getOrDefault("{\"error\":\"camera_state_exception\"}") else "{\"error\":\"NOT_BOUND\"}"
|
repo.requestDongleStateRefreshAsync("availability-poll")
|
||||||
// Phase4B-v11.6: never call raw getDongleStateJson() from the periodic home-card
|
MotorTelemetrySnapshotCache.requestRuntimeStateRefreshAsync(repo, "availability-poll")
|
||||||
// detector. After long idle it can block a binder/debug path and indirectly make
|
}
|
||||||
// Dongle detail read timeout. Dongle card can still become ACTIVE from recent
|
val handleJson = if (bound) repo.getCachedRawHandleStateJsonOrUnavailable() else "{\"error\":\"NOT_BOUND\"}"
|
||||||
// handle progress; raw USB/slot inspection should be moved to a separate
|
// Phase4B-v11.9: home-card availability must be cold-start safe and never block on
|
||||||
// RuntimeHost non-blocking API later.
|
// RuntimeHost debug/binder reads. Camera/Dongle/Telemetry home cards use binder-bound
|
||||||
val dongleJson = if (bound) repo.getDongleSafePlaceholderStateJson() else "{\"error\":\"NOT_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 handleSignals = updateHandleSignals(bound, handleJson, dongleJson)
|
||||||
|
|
||||||
val result = ComponentType.entries.associateWith { type ->
|
val result = ComponentType.entries.associateWith { type ->
|
||||||
@@ -109,6 +139,7 @@ class ComponentRegistry(
|
|||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val dongle = parseDongleSignal(bound, dongleJson)
|
val dongle = parseDongleSignal(bound, dongleJson)
|
||||||
if (!bound || isUnavailable(handleJson)) {
|
if (!bound || isUnavailable(handleJson)) {
|
||||||
|
latestHandleSignalsForMotorSlot = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER", source = "handle-unavailable")
|
||||||
return HandleDashboardSignal(
|
return HandleDashboardSignal(
|
||||||
valid = false,
|
valid = false,
|
||||||
dongle = dongle,
|
dongle = dongle,
|
||||||
@@ -119,8 +150,14 @@ class ComponentRegistry(
|
|||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
val obj = JSONObject(handleJson)
|
val obj = JSONObject(handleJson)
|
||||||
val left = parseHandleSignal("left", obj.optJSONObject("left"))
|
val handleCacheAgeMs = obj.optLong("cacheAgeMs", Long.MAX_VALUE)
|
||||||
val right = parseHandleSignal("right", obj.optJSONObject("right"))
|
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(
|
val leftProgress = updateSequenceProgress(
|
||||||
side = "left",
|
side = "left",
|
||||||
@@ -142,15 +179,18 @@ class ComponentRegistry(
|
|||||||
valid = true,
|
valid = true,
|
||||||
left = left,
|
left = left,
|
||||||
right = right,
|
right = right,
|
||||||
dongleStateLocated = obj.optBoolean("dongleStateLocated", false),
|
dongleStateLocated = obj.optBoolean("dongleStateLocated", obj.optJSONObject("debugTrace")?.optBoolean("dongleStateLocated", false) ?: false),
|
||||||
observationBufferLocated = obj.optBoolean("observationBufferLocated", false),
|
observationBufferLocated = obj.optBoolean("observationBufferLocated", obj.optJSONObject("debugTrace")?.optBoolean("observationBufferLocated", false) ?: false),
|
||||||
dongle = dongle,
|
dongle = dongle,
|
||||||
|
motorSlot = motorSlot,
|
||||||
leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs),
|
leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs),
|
||||||
rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs),
|
rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs),
|
||||||
dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs)
|
dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs),
|
||||||
|
handleCacheAgeMs = handleCacheAgeMs
|
||||||
)
|
)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.w(TAG, "updateHandleSignals exception: ${t.message}")
|
Log.w(TAG, "updateHandleSignals exception: ${t.message}")
|
||||||
|
latestHandleSignalsForMotorSlot = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER", source = "parse-exception")
|
||||||
HandleDashboardSignal(
|
HandleDashboardSignal(
|
||||||
valid = false,
|
valid = false,
|
||||||
dongle = dongle,
|
dongle = dongle,
|
||||||
@@ -161,8 +201,17 @@ class ComponentRegistry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseHandleSignal(side: String, obj: JSONObject?): HandleSignal {
|
private fun parseHandleSignal(side: String, obj: JSONObject?, fallbackSlot: SlotSignal = SlotSignal()): HandleSignal {
|
||||||
if (obj == null) return HandleSignal(side)
|
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(
|
return HandleSignal(
|
||||||
side = side,
|
side = side,
|
||||||
available = obj.optBoolean("available", false),
|
available = obj.optBoolean("available", false),
|
||||||
@@ -172,10 +221,37 @@ class ComponentRegistry(
|
|||||||
dataAgeMs = obj.optLong("dataAgeMs", -1L),
|
dataAgeMs = obj.optLong("dataAgeMs", -1L),
|
||||||
queueSize = obj.optInt("queueSize", 0),
|
queueSize = obj.optInt("queueSize", 0),
|
||||||
health = obj.optString("health", ""),
|
health = obj.optString("health", ""),
|
||||||
source = obj.optString("source", "")
|
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 {
|
private fun parseDongleSignal(bound: Boolean, json: String): DongleSignal {
|
||||||
@@ -222,6 +298,21 @@ class ComponentRegistry(
|
|||||||
transport.optBoolean("open", false),
|
transport.optBoolean("open", false),
|
||||||
transport.optBoolean("isOpen", 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) ||
|
val stateActive = state.equals("CONNECTED", ignoreCase = true) ||
|
||||||
state.equals("RUNNING", ignoreCase = true) ||
|
state.equals("RUNNING", ignoreCase = true) ||
|
||||||
state.equals("OPEN", ignoreCase = true) ||
|
state.equals("OPEN", ignoreCase = true) ||
|
||||||
@@ -229,16 +320,30 @@ class ComponentRegistry(
|
|||||||
state.equals("ACTIVE", ignoreCase = true) ||
|
state.equals("ACTIVE", ignoreCase = true) ||
|
||||||
state.equals("READY", ignoreCase = true)
|
state.equals("READY", ignoreCase = true)
|
||||||
|
|
||||||
val active = usbPresent && usbBridgeRegistered && readLoopRunning && (activeFlags || stateActive)
|
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(
|
DongleSignal(
|
||||||
valid = true,
|
valid = true,
|
||||||
active = active,
|
active = active,
|
||||||
usbPresent = usbPresent,
|
usbPresent = usbPresent,
|
||||||
usbBridgeRegistered = usbBridgeRegistered,
|
usbBridgeRegistered = usbBridgeRegistered,
|
||||||
readLoopRunning = readLoopRunning,
|
readLoopRunning = readLoopRunning,
|
||||||
|
stale = effectiveStale,
|
||||||
|
dataStale = effectiveDataStale,
|
||||||
|
lastUsbInAgeMs = lastUsbInAgeMs,
|
||||||
|
cacheAgeMs = cacheAgeMs,
|
||||||
state = state,
|
state = state,
|
||||||
source = obj.optString("source", "getDongleStateJson"),
|
source = obj.optString("source", "getDongleStateJson"),
|
||||||
evidence = "usbPresent=$usbPresent bridge=$usbBridgeRegistered readLoop=$readLoopRunning activeFlags=$activeFlags state=$state"
|
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) {
|
} catch (t: Throwable) {
|
||||||
Log.w(TAG, "parseDongleSignal exception: ${t.message}")
|
Log.w(TAG, "parseDongleSignal exception: ${t.message}")
|
||||||
@@ -252,6 +357,10 @@ class ComponentRegistry(
|
|||||||
return values.firstOrNull { !it.isNullOrBlank() } ?: ""
|
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 {
|
private fun updateSequenceProgress(side: String, sequenceId: Long, hasLiveFlags: Boolean, now: Long): Boolean {
|
||||||
if (!hasLiveFlags || sequenceId <= 0L) return false
|
if (!hasLiveFlags || sequenceId <= 0L) return false
|
||||||
return when (side) {
|
return when (side) {
|
||||||
@@ -294,13 +403,16 @@ class ComponentRegistry(
|
|||||||
|
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"Phase4B-v11.6 dashboard availability; bound=$bound; " +
|
"Phase4B-v11.9 dashboard availability; bound=$bound; " +
|
||||||
"camera=${states[ComponentType.CAMERA]}; right=${states[ComponentType.RIGHT_HANDLE]}; " +
|
"camera=${states[ComponentType.CAMERA]}; right=${states[ComponentType.RIGHT_HANDLE]}; " +
|
||||||
"dongle=${states[ComponentType.DONGLE]}; telemetry=${states[ComponentType.TELEMETRY_SAFETY]}; motor=${states[ComponentType.MOTOR]}; " +
|
"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.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.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)}; " +
|
"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.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"
|
"statsActive=$statsActive; cameraRunning=$cameraRunning; imx415OpenSucceeded=$imxOpen; rule=$DONGLE_RULE"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -325,7 +437,7 @@ class ComponentRegistry(
|
|||||||
result = "${oldState.name} -> ${newState.name}",
|
result = "${oldState.name} -> ${newState.name}",
|
||||||
isStateChange = true
|
isStateChange = true
|
||||||
))
|
))
|
||||||
Log.i(TAG, "Phase4B-v11.6 availability_change; component=${type.name}; ${oldState.name}->${newState.name}")
|
Log.i(TAG, "Phase4B-v11.9 availability_change; component=${type.name}; ${oldState.name}->${newState.name}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,7 +466,9 @@ class ComponentRegistry(
|
|||||||
if (type == ComponentType.RUNTIME_HOST_STATUS) return ComponentState.ACTIVE
|
if (type == ComponentType.RUNTIME_HOST_STATUS) return ComponentState.ACTIVE
|
||||||
if (!bound) return ComponentState.GREY
|
if (!bound) return ComponentState.GREY
|
||||||
return when (type) {
|
return when (type) {
|
||||||
ComponentType.CAMERA -> checkCamera(runtimeStats, cameraJson)
|
// Phase4B-v11.9: do not let camera/home-card availability depend on a live camera
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
@@ -411,12 +525,19 @@ class ComponentRegistry(
|
|||||||
val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS
|
val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS
|
||||||
val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS
|
val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS
|
||||||
val hasPayload = signal.sequenceId > 0L && signal.queueSize > 0
|
val hasPayload = signal.sequenceId > 0L && signal.queueSize > 0
|
||||||
val active = liveFlags && dataFresh && sequenceProgressFresh && hasPayload
|
val streamingActive = liveFlags && hasPayload && (dataFresh || sequenceProgressFresh)
|
||||||
|
val slotConnectedFresh = signal.slot.freshConnected
|
||||||
|
// Phase4B-v11.19: home card should distinguish BLE connection from IMU stream.
|
||||||
|
// A freshly connected Dongle slot is valid "connected" evidence even before
|
||||||
|
// handle IMU frames arrive; stale last-known payload still cannot keep it green.
|
||||||
|
val active = streamingActive || slotConnectedFresh
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"Phase4B-v11.6 checkHandle; side=${signal.side}; available=${signal.available}; connected=${signal.connected}; streaming=${signal.streaming}; " +
|
"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)}; " +
|
"sequenceId=${signal.sequenceId}; queueSize=${signal.queueSize}; dataAgeMs=${signal.dataAgeMs}; progressAgeMs=${printAge(progressAgeMs)}; " +
|
||||||
"dataFresh=$dataFresh; sequenceProgressFresh=$sequenceProgressFresh; decision=${if (active) "ACTIVE" else "GREY"}; rule=REQUIRE_FRESH_DATA_AND_SEQUENCE_PROGRESS"
|
"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
|
return if (active) ComponentState.ACTIVE else ComponentState.GREY
|
||||||
}
|
}
|
||||||
@@ -427,17 +548,19 @@ class ComponentRegistry(
|
|||||||
val dongleProgressFresh = handles.dongleProgressAgeMs <= DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS
|
val dongleProgressFresh = handles.dongleProgressAgeMs <= DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS
|
||||||
val recentHandleProgress = dongleProgressFresh && (rightFresh || leftFresh)
|
val recentHandleProgress = dongleProgressFresh && (rightFresh || leftFresh)
|
||||||
|
|
||||||
// Dongle card means SmartBase USB/CDC transport is alive. It must not require
|
// Phase4B-v11.17: Dongle card means SmartBase USB/CDC transport is actually alive.
|
||||||
// Right Handle IMU streaming; handles can be disconnected until a button wakes them.
|
// Do not mark it ACTIVE merely because RuntimeHost is bound, a placeholder exists,
|
||||||
// Right Handle freshness is kept only as a fallback and disconnect invalidator.
|
// 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 transportActive = handles.dongle.valid && handles.dongle.active
|
||||||
val active = handles.valid && (transportActive || recentHandleProgress)
|
val active = transportActive
|
||||||
val state = if (active) ComponentState.ACTIVE else ComponentState.GREY
|
val state = if (active) ComponentState.ACTIVE else ComponentState.GREY
|
||||||
latestDongleCardStateForMotorGate = state
|
latestDongleCardStateForMotorGate = state
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"Phase4B-v11.6 checkDongle; valid=${handles.valid}; dongleStateLocated=${handles.dongleStateLocated}; observationBufferLocated=${handles.observationBufferLocated}; " +
|
"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}; " +
|
"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}; " +
|
"rightFresh=$rightFresh; leftFresh=$leftFresh; recentHandleProgress=$recentHandleProgress; dongleProgressAgeMs=${printAge(handles.dongleProgressAgeMs)}; decision=${state.name}; " +
|
||||||
"rule=$DONGLE_RULE; evidence=${handles.dongle.evidence}"
|
"rule=$DONGLE_RULE; evidence=${handles.dongle.evidence}"
|
||||||
)
|
)
|
||||||
@@ -448,13 +571,28 @@ class ComponentRegistry(
|
|||||||
val liveFlags = signal.available || signal.connected || signal.streaming
|
val liveFlags = signal.available || signal.connected || signal.streaming
|
||||||
val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS
|
val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS
|
||||||
val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS
|
val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS
|
||||||
return liveFlags && dataFresh && sequenceProgressFresh && signal.sequenceId > 0L
|
return signal.slot.freshConnected || (liveFlags && signal.sequenceId > 0L && signal.queueSize > 0 && (dataFresh || sequenceProgressFresh))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkMotor(): ComponentState {
|
private fun checkMotor(): ComponentState {
|
||||||
// Keep strict for now: Motor is not considered green until telemetry path is verified.
|
// Phase4B-v11.14: Motor home-card state comes from explicit motor runtime-state
|
||||||
if (latestDongleCardStateForMotorGate != ComponentState.ACTIVE) return ComponentState.GREY
|
// refreshed on a background thread, not from scatter-chart visibility. This remains
|
||||||
return ComponentState.GREY
|
// no-blocking and can become ACTIVE before the user opens the Motor detail page.
|
||||||
|
val dongleActive = latestDongleCardStateForMotorGate == ComponentState.ACTIVE
|
||||||
|
val motor = MotorTelemetrySnapshotCache.homeCardRuntimeStatus(MOTOR_STATUS_FRESH_TIMEOUT_MS)
|
||||||
|
val motorSlotConnectedFresh = latestHandleSignalsForMotorSlot?.freshConnected ?: false
|
||||||
|
val active = dongleActive && (motor.active || motorSlotConnectedFresh)
|
||||||
|
val state = if (active) ComponentState.ACTIVE else ComponentState.GREY
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"Phase4B-v11.14 checkMotor; dongleActive=$dongleActive; " +
|
||||||
|
"contractOk=${motor.contractOk}; available=${motor.available}; connected=${motor.connected}; state=${motor.state}; " +
|
||||||
|
"slotState=${latestHandleSignalsForMotorSlot?.state ?: ""}; slotConnected=${latestHandleSignalsForMotorSlot?.connected ?: false}; slotAgeMs=${printAge(latestHandleSignalsForMotorSlot?.lastSeenAgeMs ?: Long.MAX_VALUE)}; " +
|
||||||
|
"blockedByDongleTransport=${motor.blockedByDongleTransport}; blockedByMotorTelemetry=${motor.blockedByMotorTelemetry}; " +
|
||||||
|
"dataAgeMs=${printAge(motor.dataAgeMs)}; snapshotAgeMs=${printAge(motor.snapshotAgeMs)}; " +
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isUnavailable(json: String): Boolean {
|
private fun isUnavailable(json: String): Boolean {
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
package com.kiwii.controlpanel.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import com.kiwii.controlpanel.R
|
||||||
|
import com.kiwii.controlpanel.data.BridgeRepository
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
|
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(
|
||||||
|
context: Context,
|
||||||
|
private val repo: BridgeRepository
|
||||||
|
) : LinearLayout(context) {
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val chartView = MotorTelemetryScatterChartView(context)
|
||||||
|
private val headline = TextView(context)
|
||||||
|
private val temperatureLine = TextView(context)
|
||||||
|
private val subline = TextView(context)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
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 {
|
||||||
|
orientation = VERTICAL
|
||||||
|
setBackgroundResource(R.drawable.bg_rhs_log_area)
|
||||||
|
setPadding(8.dp(), 8.dp(), 8.dp(), 8.dp())
|
||||||
|
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = "Realtime force / rope length / temperature"
|
||||||
|
textSize = 12f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(Color.rgb(38, 50, 56))
|
||||||
|
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||||
|
|
||||||
|
addView(headline.apply {
|
||||||
|
text = "Force: — kgf Rope: — cm"
|
||||||
|
textSize = 13f
|
||||||
|
typeface = Typeface.MONOSPACE
|
||||||
|
setTextColor(Color.rgb(20, 32, 40))
|
||||||
|
setPadding(0, 6.dp(), 0, 0)
|
||||||
|
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||||
|
|
||||||
|
addView(temperatureLine.apply {
|
||||||
|
text = "Temp: — °C"
|
||||||
|
textSize = 12f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(Color.rgb(20, 32, 40))
|
||||||
|
setPadding(0, 3.dp(), 0, 0)
|
||||||
|
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||||
|
|
||||||
|
addView(subline.apply {
|
||||||
|
text = "Phase4B-v11.17 cached telemetry poll; buttons use last-known snapshot"
|
||||||
|
textSize = 10f
|
||||||
|
setTextColor(Color.rgb(96, 111, 123))
|
||||||
|
setPadding(0, 2.dp(), 0, 6.dp())
|
||||||
|
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||||
|
|
||||||
|
addView(chartView, LayoutParams(LayoutParams.MATCH_PARENT, 320.dp()))
|
||||||
|
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = "Display convention: force is shown as positive kgf resistance/tension. This is motor-side telemetry derived from current/torque and fm_rad, not a calibrated external load-cell reading. RuntimeHost keeps rawForceN for native-sign debugging. Rope length uses extensionM, or -positionRad × fm_rad fallback."
|
||||||
|
textSize = 9.5f
|
||||||
|
setTextColor(Color.rgb(96, 111, 123))
|
||||||
|
gravity = Gravity.START
|
||||||
|
setPadding(0, 6.dp(), 0, 0)
|
||||||
|
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
attached = true
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
attached = false
|
||||||
|
stopPolling()
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
|
val current = executor
|
||||||
|
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 ->
|
||||||
|
Thread(runnable, "kiwii-motor-telemetry-panel-v11.17").apply { isDaemon = true }
|
||||||
|
}.also { exec ->
|
||||||
|
exec.scheduleWithFixedDelay({
|
||||||
|
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() {
|
||||||
|
Log.i(TAG, "Phase4B-v11.17 MotorTelemetryPanel stop")
|
||||||
|
executor?.shutdownNow()
|
||||||
|
executor = null
|
||||||
|
mainHandler.removeCallbacksAndMessages(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pollOnce() {
|
||||||
|
if (!attached) return
|
||||||
|
|
||||||
|
if (!repo.isBound()) {
|
||||||
|
postUnavailable("RuntimeHost not bound")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// This call is non-blocking for the chart scheduler. The actual Binder read runs in
|
||||||
|
// MotorTelemetrySnapshotCache's daemon refresh thread with stuck-refresh recovery.
|
||||||
|
MotorTelemetrySnapshotCache.requestRuntimeStateRefreshAsync(repo, "motor-chart-poll")
|
||||||
|
|
||||||
|
val snapshot = MotorTelemetrySnapshotCache.chartSnapshot(freshTimeoutMs = 2_000L)
|
||||||
|
val sample = snapshot.sample
|
||||||
|
if (sample == null) {
|
||||||
|
val reason = "${snapshot.reason}; effectiveAgeMs=${printAge(snapshot.effectiveAgeMs)}; snapshotAgeMs=${printAge(snapshot.snapshotAgeMs)}"
|
||||||
|
logPollNoSample(reason)
|
||||||
|
postUnavailable(reason)
|
||||||
|
} else {
|
||||||
|
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) {
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
|
||||||
|
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) {
|
||||||
|
headline.text = "Force: — kgf Rope: — cm"
|
||||||
|
temperatureLine.text = "Temp: — °C"
|
||||||
|
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 {
|
||||||
|
if (!value.isFinite()) return "—"
|
||||||
|
val absValue = kotlin.math.abs(value)
|
||||||
|
return when {
|
||||||
|
absValue >= 100.0 -> String.format("%.0f", value)
|
||||||
|
absValue >= 10.0 -> String.format("%.1f", value)
|
||||||
|
else -> String.format("%.2f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "KiwiiSDKPanelMotor"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
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 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(
|
||||||
|
val wallTimeMs: Long,
|
||||||
|
val motorRuntimeSec: Double,
|
||||||
|
val forceN: Double,
|
||||||
|
val forceKg: Double,
|
||||||
|
val ropeLengthM: Double,
|
||||||
|
val currentA: Double,
|
||||||
|
val voltageV: Double,
|
||||||
|
val tempC: Double,
|
||||||
|
val dataAgeMs: Long,
|
||||||
|
val available: Boolean,
|
||||||
|
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 {
|
||||||
|
private const val STANDARD_GRAVITY_MPS2 = 9.80665
|
||||||
|
private const val DEFAULT_PULLEY_RADIUS_M = 0.04
|
||||||
|
private const val DEFAULT_MOTOR_KT_NM_PER_A = 1.0
|
||||||
|
|
||||||
|
fun parse(json: String, nowMs: Long = System.currentTimeMillis()): MotorTelemetrySample? {
|
||||||
|
if (json.isBlank() || json.contains("NOT_BOUND")) return null
|
||||||
|
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
|
||||||
|
if (obj.optString("contractVersion", "") != "kiwii.motor-runtime-state.v1") return null
|
||||||
|
|
||||||
|
val available = obj.optBoolean("available", false)
|
||||||
|
val dataAgeMs = obj.optLong("dataAgeMs", Long.MAX_VALUE)
|
||||||
|
if (!available || dataAgeMs < 0L || dataAgeMs > 2_000L) return null
|
||||||
|
|
||||||
|
val pulleyRadiusM = obj.firstFinite(
|
||||||
|
DEFAULT_PULLEY_RADIUS_M,
|
||||||
|
"pulleyRadiusM", "fm_rad", "fmRad", "radiusM"
|
||||||
|
).takeIf { abs(it) > 1e-6 } ?: DEFAULT_PULLEY_RADIUS_M
|
||||||
|
val motorKt = obj.firstFinite(DEFAULT_MOTOR_KT_NM_PER_A, "motorKtNmPerA", "ktNmPerA")
|
||||||
|
|
||||||
|
val currentA = obj.firstFinite(0.0, "currentA", "cur", "iqA", "iq")
|
||||||
|
val torqueNm = obj.firstFinite(Double.NaN, "torqueNm", "estimatedTorqueNm")
|
||||||
|
val signedForceN = obj.firstFinite(Double.NaN, "forceN", "outputForceN", "estimatedForceN")
|
||||||
|
.takeIf { it.isFinite() }
|
||||||
|
?: run {
|
||||||
|
val rawForceN = if (torqueNm.isFinite()) torqueNm / pulleyRadiusM else currentA * motorKt / pulleyRadiusM
|
||||||
|
-rawForceN
|
||||||
|
}
|
||||||
|
val forceN = abs(signedForceN)
|
||||||
|
val forceKg = obj.firstFinite(Double.NaN, "forceKg", "forceKgEquivalent")
|
||||||
|
.takeIf { it.isFinite() }
|
||||||
|
?.let { abs(it) }
|
||||||
|
?: forceN / STANDARD_GRAVITY_MPS2
|
||||||
|
|
||||||
|
val positionRad = obj.firstFinite(Double.NaN, "positionRad", "pos")
|
||||||
|
val ropeLengthM = obj.firstFinite(Double.NaN, "ropeLengthM", "extensionM", "cableLengthM")
|
||||||
|
.takeIf { it.isFinite() }
|
||||||
|
?: if (positionRad.isFinite()) -positionRad * pulleyRadiusM else Double.NaN
|
||||||
|
if (!forceKg.isFinite() || !ropeLengthM.isFinite()) return null
|
||||||
|
|
||||||
|
return MotorTelemetrySample(
|
||||||
|
wallTimeMs = nowMs,
|
||||||
|
motorRuntimeSec = obj.firstFinite(Double.NaN, "runtimeSec", "t"),
|
||||||
|
forceN = forceN,
|
||||||
|
forceKg = forceKg,
|
||||||
|
ropeLengthM = ropeLengthM,
|
||||||
|
currentA = currentA,
|
||||||
|
voltageV = obj.firstFinite(Double.NaN, "voltageV", "volt"),
|
||||||
|
tempC = obj.firstFinite(Double.NaN, "tempC", "temp"),
|
||||||
|
dataAgeMs = dataAgeMs,
|
||||||
|
available = available,
|
||||||
|
rawJson = json
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.firstFinite(fallback: Double, vararg keys: String): Double {
|
||||||
|
for (key in keys) {
|
||||||
|
if (!has(key) || isNull(key)) continue
|
||||||
|
val value = optDouble(key, Double.NaN)
|
||||||
|
if (value.isFinite()) return value
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user