diff --git a/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt b/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt index 9f5cd03..ada3005 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt @@ -2,22 +2,52 @@ package com.kiwii.controlpanel.data import android.app.Activity import com.kiwii.bridge.KiwiiRuntimeClientBridge +import com.kiwii.controlpanel.ui.components.MotorTelemetrySnapshotCache import com.kiwii.controlpanel.model.OperationResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean class BridgeRepository { private companion object { const val HANDLE_CACHE_MAX_AGE_MS = 5000L + const val HANDLE_REFRESH_MIN_INTERVAL_MS = 1000L + const val HANDLE_REFRESH_STUCK_RESET_MS = 5000L + const val DONGLE_CACHE_MAX_AGE_MS = 3000L + const val DONGLE_REFRESH_MIN_INTERVAL_MS = 1000L + const val DONGLE_REFRESH_STUCK_RESET_MS = 5000L @Volatile private var cachedHandleStateJson: String = "" @Volatile private var cachedHandleStateAtMs: Long = 0L + + @Volatile + private var lastHandleRefreshAttemptMs: Long = 0L + + private val handleRefreshInFlight = AtomicBoolean(false) + private val handleRefreshExecutor = Executors.newCachedThreadPool { runnable -> + Thread(runnable, "SDKPanelHandleStateRefresh").apply { isDaemon = true } + } + + @Volatile + private var cachedDongleStateJson: String = "" + + @Volatile + private var cachedDongleStateAtMs: Long = 0L + + @Volatile + private var lastDongleRefreshAttemptMs: Long = 0L + + private val dongleRefreshInFlight = AtomicBoolean(false) + private val dongleRefreshExecutor = Executors.newCachedThreadPool { runnable -> + Thread(runnable, "SDKPanelDongleStateRefresh").apply { isDaemon = true } + } } fun bind(activity: Activity): Boolean = KiwiiRuntimeClientBridge.bind(activity) @@ -142,11 +172,240 @@ class BridgeRepository { fun getHandleStateJson(): String { val json = KiwiiRuntimeClientBridge.getHandleStateJson() + cacheHandleStateIfValid(json) + return json + } + + /** + * Phase4B-v11.9: best-effort live HandleState refresh without blocking UI/poller callers. + * + * Important: some RuntimeHost debug/binder paths can block after long idle. A stuck refresh + * must not permanently disable future refresh attempts, so the in-flight flag is allowed to + * expire. The cached/last-known snapshot is never cleared on failure. + */ + fun requestHandleStateRefreshAsync(reason: String = "") { + if (!isBound()) return + val now = System.currentTimeMillis() + val stuck = handleRefreshInFlight.get() && + lastHandleRefreshAttemptMs > 0L && + now - lastHandleRefreshAttemptMs > HANDLE_REFRESH_STUCK_RESET_MS + if (stuck) { + handleRefreshInFlight.set(false) + } + if (now - lastHandleRefreshAttemptMs < HANDLE_REFRESH_MIN_INTERVAL_MS) return + if (!handleRefreshInFlight.compareAndSet(false, true)) return + lastHandleRefreshAttemptMs = now + handleRefreshExecutor.execute { + try { + val json = KiwiiRuntimeClientBridge.getHandleStateJson() + cacheHandleStateIfValid(json) + } catch (_: Throwable) { + // Keep last-known snapshot. Never clear cached handle state on refresh failure. + } finally { + handleRefreshInFlight.set(false) + } + } + } + + fun getCachedRawHandleStateJsonOrUnavailable(): String { + val now = System.currentTimeMillis() + val cacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE + return if (cachedHandleStateJson.isNotBlank()) { + val obj = parseJsonOrEmpty(cachedHandleStateJson) + obj.put("uiPath", "SDK_PANEL_CACHED_HANDLE_STATE") + obj.put("cacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs) + obj.put("cacheFresh", cacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS) + obj.toString() + } else { + JSONObject() + .put("error", "no-cached-handle-state") + .put("source", "SDK_Panel.cached-handle-state.v11.18") + .put("cacheAgeMs", -1L) + .put("cacheFresh", false) + .toString() + } + } + + /** + * Phase4B-v11.17: best-effort live Dongle transport refresh without blocking + * home-card rendering. This reads RuntimeHost's fast transport-only dongle state; + * the cached result is then used by both the home card and readDongleState detail. + */ + fun requestDongleStateRefreshAsync(reason: String = "") { + if (!isBound()) return + val now = System.currentTimeMillis() + val stuck = dongleRefreshInFlight.get() && + lastDongleRefreshAttemptMs > 0L && + now - lastDongleRefreshAttemptMs > DONGLE_REFRESH_STUCK_RESET_MS + if (stuck) { + dongleRefreshInFlight.set(false) + } + if (now - lastDongleRefreshAttemptMs < DONGLE_REFRESH_MIN_INTERVAL_MS) return + if (!dongleRefreshInFlight.compareAndSet(false, true)) return + lastDongleRefreshAttemptMs = now + dongleRefreshExecutor.execute { + try { + val json = KiwiiRuntimeClientBridge.getDongleStateJson() + cacheDongleStateIfValid(json) + } catch (_: Throwable) { + // Keep last-known dongle state. Never clear cached state on refresh failure. + } finally { + dongleRefreshInFlight.set(false) + } + } + } + + fun getCachedRawDongleStateJsonOrUnavailable(): String { + val now = System.currentTimeMillis() + val cacheAgeMs = if (cachedDongleStateAtMs > 0L) now - cachedDongleStateAtMs else Long.MAX_VALUE + return if (cachedDongleStateJson.isNotBlank()) { + val obj = parseJsonOrEmpty(cachedDongleStateJson) + obj.put("uiPath", "SDK_PANEL_CACHED_DONGLE_STATE") + obj.put("cacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs) + obj.put("cacheFresh", cacheAgeMs in 0..DONGLE_CACHE_MAX_AGE_MS) + obj.toString() + } else { + JSONObject() + .put("contractVersion", "kiwii.sdk-panel.cached-dongle-state.v11.17") + .put("error", "no-cached-dongle-state") + .put("source", "SDK_Panel.cached-dongle-state") + .put("cacheAgeMs", -1L) + .put("cacheFresh", false) + .toString() + } + } + + private fun cacheDongleStateIfValid(json: String) { + if (json.isNotBlank() && !json.contains("\"error\"")) { + cachedDongleStateJson = json + cachedDongleStateAtMs = System.currentTimeMillis() + } + } + + private fun cacheHandleStateIfValid(json: String) { if (json.isNotBlank() && !json.contains("\"error\"")) { cachedHandleStateJson = json cachedHandleStateAtMs = System.currentTimeMillis() } - 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() + 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(). * 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. */ fun getDongleDetailStateJson(): String { - // Phase4B-v11.6: readDongleState and Dongle detail State must be zero-blocking. - // Do not call getDongleStateJson() or getHandleStateJson() here. Both are binder calls - // and either one can become slow after long idle periods. Use the latest cached - // HandleState populated by the home-card poller; if the cache is stale, return a - // responsive safe state instead of timing out the UI. + // Phase4B-v11.17: readDongleState remains zero-blocking, but no longer uses a + // fake NOT_QUERIED placeholder as the primary state. The home-card poller keeps a + // cached RuntimeHost dongle transport snapshot; this method displays that cached + // raw state plus inferred slot evidence from cached Handle/Motor snapshots. + requestDongleStateRefreshAsync("read-dongle-state-click") val now = System.currentTimeMillis() - val cacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE - val handleRaw = if (cachedHandleStateJson.isNotBlank() && cacheAgeMs <= HANDLE_CACHE_MAX_AGE_MS) { + val handleCacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE + val handleRaw = if (cachedHandleStateJson.isNotBlank()) { cachedHandleStateJson } else { JSONObject() - .put("error", "no-fresh-cached-handle-state") - .put("handleCacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs) - .put("detail", "Safe Dongle detail path uses cached HandleState only; live binder query skipped to avoid idle timeout.") + .put("error", "no-cached-handle-state") + .put("handleCacheAgeMs", if (handleCacheAgeMs == Long.MAX_VALUE) -1L else handleCacheAgeMs) + .put("detail", "Safe Dongle detail path uses last-known HandleState only; live binder query skipped to avoid idle timeout.") .toString() } return buildDongleDetailStateJson( - buildSafeDonglePlaceholderJson(), + getCachedRawDongleStateJsonOrUnavailable(), 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 { 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("rawDongleQuery", "disabled-on-detail-ui-path") .put("transport", JSONObject() @@ -243,29 +502,31 @@ class BridgeRepository { val dongle = parseJsonOrEmpty(dongleRaw) val handle = parseJsonOrEmpty(handleRaw) val out = JSONObject() - out.put("contractVersion", "kiwii.sdk-panel.dongle-detail-state.v11.6") - out.put("source", "SDK_Panel.safe-handle-state-dongle-detail") - out.put("diagnosticSemantics", "operational-state-first; no-live-binder-call-on-dongle-detail; raw-slot-report-secondary") + out.put("contractVersion", "kiwii.sdk-panel.dongle-detail-state.v11.17") + out.put("source", "SDK_Panel.cached-dongle-and-handle-state-detail") + out.put("diagnosticSemantics", "transport-state-first; no-live-binder-call-on-dongle-detail; inferred-slot-report-secondary") out.put("handleCacheAgeMs", handleCacheAgeMs) out.put("handleCacheFresh", handleCacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS) + out.put("handleCacheStale", handleCacheAgeMs > HANDLE_CACHE_MAX_AGE_MS) out.put("generatedAtMs", System.currentTimeMillis()) out.put("dongle", dongle) out.put("handleState", handle) val transport = dongle.optJSONObject("transport") ?: JSONObject() out.put("transport", transport) - out.put("slots", buildSdkPanelSlots(dongle, handle)) + 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) 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 slots = JSONArray() val left = handle.optJSONObject("left") ?: JSONObject() val right = handle.optJSONObject("right") ?: JSONObject() + val motorSlotEvidence = MotorTelemetrySnapshotCache.dongleSlotEvidenceJson() for (slotIndex in 0..3) { val reported = findSlotObject(slotIndex, sourceSlots) @@ -273,21 +534,29 @@ class BridgeRepository { slot.put("slot", slotIndex) val inferredHandle = when (slotIndex) { - 0 -> if (isFreshHandle(left)) "LEFT_HANDLE" else "" - 1 -> if (isFreshHandle(right)) "RIGHT_HANDLE" else "" + 0 -> if (hasFreshHandleSlotEvidence(left, handleCacheAgeMs)) "LEFT_HANDLE" else "" + 1 -> if (hasFreshHandleSlotEvidence(right, handleCacheAgeMs)) "RIGHT_HANDLE" 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 slot.put("state", "CONNECTED") slot.put("connected", true) slot.put("device", inferredHandle) - slot.put("source", "handle-state-inferred") + slot.put("source", "handle-state-fresh-inferred") slot.put("inferred", true) slot.put("sequenceId", handleObj.optLong("sequenceId", handleObj.optLong("seq", 0L))) slot.put("dataAgeMs", handleObj.optLong("dataAgeMs", -1L)) slot.put("queueSize", handleObj.optInt("queueSize", 0)) + slot.put("stale", handleCacheAgeMs > HANDLE_CACHE_MAX_AGE_MS) + slot.put("evidenceAgeMs", handleCacheAgeMs) val reportedState = reported?.optString("state", reported.optString("status", "")) ?: "" val rawSlotReport = when { reported == null -> if (dongle.optString("rawDongleQuery", "") == "disabled-on-detail-ui-path") "not-queried" else "missing" @@ -297,11 +566,11 @@ class BridgeRepository { } slot.put("rawSlotReport", rawSlotReport) 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) { - slot.put("detail", "Connected via handle-state; rawSlot=$rawSlotReport") + slot.put("detail", "Connected via fresh handle-state; rawSlot=$rawSlotReport") } else { - slot.put("detail", "Connected via handle-state") + slot.put("detail", "Connected via fresh handle-state") } } else if (reported != null) { val connected = reported.optBooleanFlexible("connected") || @@ -338,21 +607,20 @@ class BridgeRepository { 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 right = handle.optJSONObject("right") ?: JSONObject() - val rightFresh = isFreshHandle(right) + val rightFresh = hasFreshHandleSlotEvidence(right, handleCacheAgeMs) val reportedSlot1 = findSlotObject(1, sourceSlots) val reportedSlot1Connected = reportedSlot1?.let { it.optBooleanFlexible("connected") || it.optBooleanFlexible("available") || it.optBooleanFlexible("active") || it.optString("state", "").equals("CONNECTED", ignoreCase = true) || it.optString("status", "").equals("CONNECTED", ignoreCase = true) } ?: false - return if (rightFresh && !reportedSlot1Connected) { - "Slot 1 inferred from cached Right Handle data." - } else { - "" - } + val notes = mutableListOf() + if (rightFresh && !reportedSlot1Connected) notes += "Slot 1 inferred from cached Right Handle data." + if (MotorTelemetrySnapshotCache.dongleSlotEvidenceJson() != null) notes += "Slot 3 inferred from explicit Motor runtime state." + return notes.joinToString(" ") } private fun firstSlotArray(obj: JSONObject): JSONArray? { @@ -374,15 +642,16 @@ class BridgeRepository { return slots.optJSONObject(slotIndex) } - private fun isFreshHandle(handle: JSONObject): Boolean { + private fun hasFreshHandleSlotEvidence(handle: JSONObject, handleCacheAgeMs: Long): Boolean { val available = handle.optBooleanFlexible("available") val connected = handle.optBooleanFlexible("connected") val streaming = handle.optBooleanFlexible("streaming") val seq = handle.optLong("sequenceId", handle.optLong("seq", 0L)) val queueSize = handle.optInt("queueSize", 0) - val age = handle.optLong("dataAgeMs", -1L) - val ageFresh = age in 0..2500 - return (available || connected || streaming) && (ageFresh || (seq > 0L && queueSize > 0)) + val dataAgeMs = handle.optLong("dataAgeMs", Long.MAX_VALUE) + val cacheFresh = handleCacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS + val dataFresh = dataAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS + return cacheFresh && dataFresh && (available || connected || streaming) && seq > 0L && queueSize > 0 } private fun parseJsonOrEmpty(raw: String): JSONObject { diff --git a/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt b/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt index 6323557..069a4a6 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt @@ -6,6 +6,7 @@ import com.kiwii.controlpanel.logging.SessionLogger import com.kiwii.controlpanel.model.ComponentCategory import com.kiwii.controlpanel.model.ComponentState import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.ui.components.MotorTelemetrySnapshotCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -30,7 +31,23 @@ class ComponentRegistry( const val HANDLE_DATA_AGE_FRESH_TIMEOUT_MS = 1500L const val HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L const val DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L - const val DONGLE_RULE = "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( @@ -42,7 +59,8 @@ class ComponentRegistry( val dataAgeMs: Long = -1L, val queueSize: Int = 0, val health: String = "", - val source: String = "" + val source: String = "", + val slot: SlotSignal = SlotSignal() ) private data class DongleSignal( @@ -51,6 +69,10 @@ class ComponentRegistry( val usbPresent: Boolean = false, val usbBridgeRegistered: Boolean = false, val readLoopRunning: Boolean = false, + val stale: Boolean = true, + val dataStale: Boolean = true, + val lastUsbInAgeMs: Long = Long.MAX_VALUE, + val cacheAgeMs: Long = Long.MAX_VALUE, val state: String = "", val source: String = "", val evidence: String = "" @@ -63,14 +85,19 @@ class ComponentRegistry( val dongleStateLocated: Boolean = false, val observationBufferLocated: Boolean = false, val dongle: DongleSignal = DongleSignal(), + val motorSlot: SlotSignal = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER"), val leftProgressAgeMs: Long = Long.MAX_VALUE, val rightProgressAgeMs: Long = Long.MAX_VALUE, - val dongleProgressAgeMs: Long = Long.MAX_VALUE + val dongleProgressAgeMs: Long = Long.MAX_VALUE, + val handleCacheAgeMs: Long = Long.MAX_VALUE ) @Volatile private var latestDongleCardStateForMotorGate: ComponentState = ComponentState.GREY + @Volatile + private var latestHandleSignalsForMotorSlot: SlotSignal? = null + private var lastLeftSequenceId: Long = 0L private var lastRightSequenceId: Long = 0L private var lastLeftSequenceProgressWallMs: Long = 0L @@ -81,15 +108,18 @@ class ComponentRegistry( scope.launch(Dispatchers.IO) { while (isActive) { val bound = repo.isBound() - val handleJson = if (bound) runCatching { repo.getHandleStateJson() }.getOrDefault("{\"error\":\"handle_state_exception\"}") else "{\"error\":\"NOT_BOUND\"}" - val runtimeStats = if (bound) runCatching { repo.getLatestRuntimeStats() }.getOrDefault(FloatArray(0)) else FloatArray(0) - val cameraJson = if (bound) runCatching { repo.getCameraStateJson() }.getOrDefault("{\"error\":\"camera_state_exception\"}") else "{\"error\":\"NOT_BOUND\"}" - // Phase4B-v11.6: never call raw getDongleStateJson() from the periodic home-card - // 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 - // handle progress; raw USB/slot inspection should be moved to a separate - // RuntimeHost non-blocking API later. - val dongleJson = if (bound) repo.getDongleSafePlaceholderStateJson() else "{\"error\":\"NOT_BOUND\"}" + if (bound) { + repo.requestHandleStateRefreshAsync("availability-poll") + repo.requestDongleStateRefreshAsync("availability-poll") + MotorTelemetrySnapshotCache.requestRuntimeStateRefreshAsync(repo, "availability-poll") + } + val handleJson = if (bound) repo.getCachedRawHandleStateJsonOrUnavailable() else "{\"error\":\"NOT_BOUND\"}" + // Phase4B-v11.9: home-card availability must be cold-start safe and never block on + // RuntimeHost debug/binder reads. Camera/Dongle/Telemetry home cards use binder-bound + // runtime availability; detailed state buttons remain no-blocking/last-known. + val runtimeStats = FloatArray(0) + val cameraJson = if (bound) "{\"contractVersion\":\"kiwii.sdk-panel.camera-bound-placeholder.v11.9\",\"state\":\"BOUND_RUNTIME\",\"running\":true}" else "{\"error\":\"NOT_BOUND\"}" + val dongleJson = if (bound) repo.getCachedRawDongleStateJsonOrUnavailable() else "{\"error\":\"NOT_BOUND\"}" val handleSignals = updateHandleSignals(bound, handleJson, dongleJson) val result = ComponentType.entries.associateWith { type -> @@ -109,6 +139,7 @@ class ComponentRegistry( val now = System.currentTimeMillis() val dongle = parseDongleSignal(bound, dongleJson) if (!bound || isUnavailable(handleJson)) { + latestHandleSignalsForMotorSlot = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER", source = "handle-unavailable") return HandleDashboardSignal( valid = false, dongle = dongle, @@ -119,8 +150,14 @@ class ComponentRegistry( } return try { val obj = JSONObject(handleJson) - val left = parseHandleSignal("left", obj.optJSONObject("left")) - val right = parseHandleSignal("right", obj.optJSONObject("right")) + val handleCacheAgeMs = obj.optLong("cacheAgeMs", Long.MAX_VALUE) + val slots = obj.optJSONArray("dongleSlots") + val leftSlot = parseSlotSignal(slots, 0, 0x11, "LEFT_HANDLE") + val rightSlot = parseSlotSignal(slots, 1, 0x12, "RIGHT_HANDLE") + val motorSlot = parseSlotSignal(slots, 3, 0x31, "MOTOR_POWER") + latestHandleSignalsForMotorSlot = motorSlot + val left = parseHandleSignal("left", obj.optJSONObject("left"), leftSlot) + val right = parseHandleSignal("right", obj.optJSONObject("right"), rightSlot) val leftProgress = updateSequenceProgress( side = "left", @@ -142,15 +179,18 @@ class ComponentRegistry( valid = true, left = left, right = right, - dongleStateLocated = obj.optBoolean("dongleStateLocated", false), - observationBufferLocated = obj.optBoolean("observationBufferLocated", false), + dongleStateLocated = obj.optBoolean("dongleStateLocated", obj.optJSONObject("debugTrace")?.optBoolean("dongleStateLocated", false) ?: false), + observationBufferLocated = obj.optBoolean("observationBufferLocated", obj.optJSONObject("debugTrace")?.optBoolean("observationBufferLocated", false) ?: false), dongle = dongle, + motorSlot = motorSlot, leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs), rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs), - dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs) + dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs), + handleCacheAgeMs = handleCacheAgeMs ) } catch (t: Throwable) { Log.w(TAG, "updateHandleSignals exception: ${t.message}") + latestHandleSignalsForMotorSlot = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER", source = "parse-exception") HandleDashboardSignal( valid = false, dongle = dongle, @@ -161,8 +201,17 @@ class ComponentRegistry( } } - private fun parseHandleSignal(side: String, obj: JSONObject?): HandleSignal { - if (obj == null) return HandleSignal(side) + private fun parseHandleSignal(side: String, obj: JSONObject?, fallbackSlot: SlotSignal = SlotSignal()): HandleSignal { + if (obj == null) return HandleSignal(side, slot = fallbackSlot) + val embeddedSlot = SlotSignal( + slot = obj.optInt("slot", fallbackSlot.slot), + dev = obj.optInt("slotDev", fallbackSlot.dev), + name = firstNonBlank(obj.optString("slotName", ""), fallbackSlot.name), + state = firstNonBlank(obj.optString("slotState", ""), fallbackSlot.state), + connected = obj.optBoolean("slotConnected", fallbackSlot.connected), + lastSeenAgeMs = firstNonNegativeLong(obj.optLong("slotLastSeenAgeMs", Long.MIN_VALUE), fallbackSlot.lastSeenAgeMs), + source = "handleState.embeddedSlot" + ) return HandleSignal( side = side, available = obj.optBoolean("available", false), @@ -172,10 +221,37 @@ class ComponentRegistry( dataAgeMs = obj.optLong("dataAgeMs", -1L), queueSize = obj.optInt("queueSize", 0), 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 { @@ -222,6 +298,21 @@ class ComponentRegistry( transport.optBoolean("open", false), transport.optBoolean("isOpen", false) ) + val stale = boolAny( + obj.optBoolean("stale", false), + obj.optBoolean("dataStale", false), + transport.optBoolean("stale", false), + transport.optBoolean("dataStale", false) + ) + val dataStale = boolAny( + obj.optBoolean("dataStale", false), + transport.optBoolean("dataStale", false) + ) + val lastUsbInAgeMs = firstNonNegativeLong( + transport.optLong("lastUsbInAgeMs", Long.MIN_VALUE), + obj.optLong("lastUsbInAgeMs", Long.MIN_VALUE) + ) + val cacheAgeMs = obj.optLong("cacheAgeMs", Long.MAX_VALUE) val stateActive = state.equals("CONNECTED", ignoreCase = true) || state.equals("RUNNING", ignoreCase = true) || state.equals("OPEN", ignoreCase = true) || @@ -229,16 +320,30 @@ class ComponentRegistry( state.equals("ACTIVE", 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( valid = true, active = active, usbPresent = usbPresent, usbBridgeRegistered = usbBridgeRegistered, readLoopRunning = readLoopRunning, + stale = effectiveStale, + dataStale = effectiveDataStale, + lastUsbInAgeMs = lastUsbInAgeMs, + cacheAgeMs = cacheAgeMs, state = state, source = obj.optString("source", "getDongleStateJson"), - evidence = "usbPresent=$usbPresent bridge=$usbBridgeRegistered readLoop=$readLoopRunning activeFlags=$activeFlags state=$state" + evidence = "usbPresent=$usbPresent bridge=$usbBridgeRegistered readLoop=$readLoopRunning activeFlags=$activeFlags state=$state rawStale=$stale rawDataStale=$dataStale effectiveStale=$effectiveStale lastUsbInAgeMs=${printAge(lastUsbInAgeMs)} cacheAgeMs=${printAge(cacheAgeMs)}" ) } catch (t: Throwable) { Log.w(TAG, "parseDongleSignal exception: ${t.message}") @@ -252,6 +357,10 @@ class ComponentRegistry( return values.firstOrNull { !it.isNullOrBlank() } ?: "" } + private fun firstNonNegativeLong(vararg values: Long): Long { + return values.firstOrNull { it >= 0L } ?: Long.MAX_VALUE + } + private fun updateSequenceProgress(side: String, sequenceId: Long, hasLiveFlags: Boolean, now: Long): Boolean { if (!hasLiveFlags || sequenceId <= 0L) return false return when (side) { @@ -294,13 +403,16 @@ class ComponentRegistry( Log.i( TAG, - "Phase4B-v11.6 dashboard availability; bound=$bound; " + + "Phase4B-v11.9 dashboard availability; bound=$bound; " + "camera=${states[ComponentType.CAMERA]}; right=${states[ComponentType.RIGHT_HANDLE]}; " + "dongle=${states[ComponentType.DONGLE]}; telemetry=${states[ComponentType.TELEMETRY_SAFETY]}; motor=${states[ComponentType.MOTOR]}; " + "right.available=${handles.right.available}; right.connected=${handles.right.connected}; right.streaming=${handles.right.streaming}; " + - "right.seq=${handles.right.sequenceId}; right.dataAgeMs=${handles.right.dataAgeMs}; right.progressAgeMs=${printAge(handles.rightProgressAgeMs)}; " + + "right.seq=${handles.right.sequenceId}; right.dataAgeMs=${handles.right.dataAgeMs}; right.progressAgeMs=${printAge(handles.rightProgressAgeMs)}; right.cacheAgeMs=${printAge(handles.handleCacheAgeMs)}; " + + "right.slotState=${handles.right.slot.state}; right.slotConnected=${handles.right.slot.connected}; right.slotAgeMs=${printAge(handles.right.slot.lastSeenAgeMs)}; " + + "motor.slotState=${handles.motorSlot.state}; motor.slotConnected=${handles.motorSlot.connected}; motor.slotAgeMs=${printAge(handles.motorSlot.lastSeenAgeMs)}; " + "dongleStateLocated=${handles.dongleStateLocated}; observationBufferLocated=${handles.observationBufferLocated}; dongle.progressAgeMs=${printAge(handles.dongleProgressAgeMs)}; " + "dongle.usbActive=${handles.dongle.active}; dongle.usbPresent=${handles.dongle.usbPresent}; dongle.bridge=${handles.dongle.usbBridgeRegistered}; dongle.readLoop=${handles.dongle.readLoopRunning}; dongle.transportState=${handles.dongle.state}; " + + "dongle.stale=${handles.dongle.stale}; dongle.dataStale=${handles.dongle.dataStale}; dongle.lastUsbInAgeMs=${printAge(handles.dongle.lastUsbInAgeMs)}; dongle.cacheAgeMs=${printAge(handles.dongle.cacheAgeMs)}; " + "statsActive=$statsActive; cameraRunning=$cameraRunning; imx415OpenSucceeded=$imxOpen; rule=$DONGLE_RULE" ) } @@ -325,7 +437,7 @@ class ComponentRegistry( result = "${oldState.name} -> ${newState.name}", 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 (!bound) return ComponentState.GREY 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 else -> ComponentState.ACTIVE } @@ -411,12 +525,19 @@ class ComponentRegistry( val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS val hasPayload = signal.sequenceId > 0L && signal.queueSize > 0 - val active = liveFlags && dataFresh && sequenceProgressFresh && hasPayload + val streamingActive = liveFlags && hasPayload && (dataFresh || sequenceProgressFresh) + val slotConnectedFresh = signal.slot.freshConnected + // Phase4B-v11.19: home card should distinguish BLE connection from IMU stream. + // A freshly connected Dongle slot is valid "connected" evidence even before + // handle IMU frames arrive; stale last-known payload still cannot keep it green. + val active = streamingActive || slotConnectedFresh Log.i( TAG, - "Phase4B-v11.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)}; " + - "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 } @@ -427,17 +548,19 @@ class ComponentRegistry( val dongleProgressFresh = handles.dongleProgressAgeMs <= DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS val recentHandleProgress = dongleProgressFresh && (rightFresh || leftFresh) - // Dongle card means SmartBase USB/CDC transport is alive. It must not require - // Right Handle IMU streaming; handles can be disconnected until a button wakes them. - // Right Handle freshness is kept only as a fallback and disconnect invalidator. + // Phase4B-v11.17: Dongle card means SmartBase USB/CDC transport is actually alive. + // Do not mark it ACTIVE merely because RuntimeHost is bound, a placeholder exists, + // or a last-known Handle sample exists. Handles are useful detail-page slot evidence, + // but they are not sufficient transport evidence for the Dongle home card. val transportActive = handles.dongle.valid && handles.dongle.active - val active = handles.valid && (transportActive || recentHandleProgress) + val active = transportActive val state = if (active) ComponentState.ACTIVE else ComponentState.GREY latestDongleCardStateForMotorGate = state Log.i( 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}; " + + "stale=${handles.dongle.stale}; dataStale=${handles.dongle.dataStale}; lastUsbInAgeMs=${printAge(handles.dongle.lastUsbInAgeMs)}; cacheAgeMs=${printAge(handles.dongle.cacheAgeMs)}; " + "rightFresh=$rightFresh; leftFresh=$leftFresh; recentHandleProgress=$recentHandleProgress; dongleProgressAgeMs=${printAge(handles.dongleProgressAgeMs)}; decision=${state.name}; " + "rule=$DONGLE_RULE; evidence=${handles.dongle.evidence}" ) @@ -448,13 +571,28 @@ class ComponentRegistry( val liveFlags = signal.available || signal.connected || signal.streaming val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS - return liveFlags && dataFresh && sequenceProgressFresh && signal.sequenceId > 0L + return signal.slot.freshConnected || (liveFlags && signal.sequenceId > 0L && signal.queueSize > 0 && (dataFresh || sequenceProgressFresh)) } private fun checkMotor(): ComponentState { - // Keep strict for now: Motor is not considered green until telemetry path is verified. - if (latestDongleCardStateForMotorGate != ComponentState.ACTIVE) return ComponentState.GREY - return ComponentState.GREY + // Phase4B-v11.14: Motor home-card state comes from explicit motor runtime-state + // refreshed on a background thread, not from scatter-chart visibility. This remains + // 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 { diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorRealtimeTelemetryPanel.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorRealtimeTelemetryPanel.kt new file mode 100644 index 0000000..beb916b --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorRealtimeTelemetryPanel.kt @@ -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" + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryModels.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryModels.kt new file mode 100644 index 0000000..6ba86c8 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryModels.kt @@ -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 + ) +}