4 Commits

11 changed files with 970 additions and 126 deletions
Binary file not shown.
@@ -16,6 +16,15 @@ class BridgeRepository {
fun getRuntimeStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeStateJson()
fun getHealthStateJson(): String = KiwiiRuntimeClientBridge.getHealthStateJson()
fun getDebugSnapshotJson(): String = KiwiiRuntimeClientBridge.getDebugSnapshotJson()
fun getDongleStateJson(): String {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod("getDongleStateJson")
method.invoke(null) as? String ?: getDebugSnapshotJson()
} catch (_: Throwable) {
// Backward-compatible fallback for old unity-bridge.aar builds.
getDebugSnapshotJson()
}
}
fun getRuntimeLifecycleStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeLifecycleStateJson()
fun requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason)
@@ -28,22 +37,62 @@ class BridgeRepository {
fun getLatestBodyPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestBodyPoseFrameJson()
fun getLeftHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLeftHandleIMULatest()
fun getLeftHandleIMUQueue(): Array<FloatArray> = KiwiiRuntimeClientBridge.getLeftHandleIMUQueue()
fun getLeftHandleIMUQueue(): Array<FloatArray> {
val queue = readHandleImuQueueCompat("getLeftHandleIMUQueue")
if (queue.isNotEmpty()) return queue
val latest = getLeftHandleIMULatest()
return if (latest.isEmpty()) emptyArray() else arrayOf(latest)
}
fun submitLeftStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitLeftStaticEffect(effectId)
fun submitLeftHeStream(streamId: Int, payload: String): String = KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, payload)
fun submitLeftHeStream(streamId: Int, payload: String): String =
KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, buildHeStreamPatternJson(streamId, payload))
fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest()
fun getRightHandleIMUQueue(): Array<FloatArray> = KiwiiRuntimeClientBridge.getRightHandleIMUQueue()
fun getRightHandleIMUQueue(): Array<FloatArray> {
val queue = readHandleImuQueueCompat("getRightHandleIMUQueue")
if (queue.isNotEmpty()) return queue
val latest = getRightHandleIMULatest()
return if (latest.isEmpty()) emptyArray() else arrayOf(latest)
}
fun submitRightStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitRightStaticEffect(effectId)
fun submitRightHeStream(streamId: Int, payload: String): String = KiwiiRuntimeClientBridge.submitRightHeStream(streamId, payload)
fun submitRightHeStream(streamId: Int, payload: String): String =
KiwiiRuntimeClientBridge.submitRightHeStream(streamId, buildHeStreamPatternJson(streamId, payload))
fun getHandleStateJson(): String = KiwiiRuntimeClientBridge.getHandleStateJson()
fun setMotorWeightKg(weight: Float): com.kiwii.bridge.MotorCommandResult = KiwiiRuntimeClientBridge.setMotorWeightKg(weight)
fun setMotorTrainingMode(mode: Int, param: Float): String = KiwiiRuntimeClientBridge.setMotorTrainingMode(mode, param)
fun setMotorForceControlParamsJson(paramsJson: String): String {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod("setMotorForceControlParamsJson", String::class.java)
method.invoke(null, paramsJson) as? String ?: "{\"error\":\"setMotorForceControlParamsJson returned null\"}"
} catch (t: Throwable) {
"{\"error\":\"setMotorForceControlParamsJson unavailable: ${t.message}\"}"
}
}
fun queryMotorTrainingMode(axis: Int): String = KiwiiRuntimeClientBridge.queryMotorTrainingMode(axis)
fun getLatestMotorStateJson(): String = KiwiiRuntimeClientBridge.getLatestMotorStateJson()
fun getMotorControlStateJson(): String = KiwiiRuntimeClientBridge.getMotorControlStateJson()
fun getMotorDisconnectDiagnosisJson(): String {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod("getMotorDisconnectDiagnosisJson")
method.invoke(null) as? String ?: "{\"error\":\"getMotorDisconnectDiagnosisJson returned null\"}"
} catch (t: Throwable) {
"{\"error\":\"getMotorDisconnectDiagnosisJson unavailable: ${t.message}\"}"
}
}
fun sendMotorHeartbeat(): String {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod("sendMotorHeartbeat")
method.invoke(null) as? String ?: "{\"error\":\"sendMotorHeartbeat returned null\"}"
} catch (t: Throwable) {
"{\"error\":\"sendMotorHeartbeat unavailable: ${t.message}\"}"
}
}
fun getTelemetryStateJson(): String = KiwiiRuntimeClientBridge.getTelemetryStateJson()
fun getSafetyStateJson(): String = KiwiiRuntimeClientBridge.getSafetyStateJson()
@@ -51,6 +100,48 @@ class BridgeRepository {
fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command)
fun getDeviceCommandStateJson(): String = KiwiiRuntimeClientBridge.getDeviceCommandStateJson()
private fun readHandleImuQueueCompat(methodName: String): Array<FloatArray> {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod(methodName)
when (val value = method.invoke(null)) {
is Array<*> -> {
@Suppress("UNCHECKED_CAST")
value.filterIsInstance<FloatArray>().toTypedArray()
}
is FloatArray -> splitFlatImuQueue(value)
else -> emptyArray()
}
} catch (_: Throwable) {
emptyArray()
}
}
private fun splitFlatImuQueue(flat: FloatArray): Array<FloatArray> {
if (flat.isEmpty()) return emptyArray()
val sampleSize = when {
flat.size % 10 == 0 -> 10
flat.size % 9 == 0 -> 9
flat.size % 7 == 0 -> 7
else -> flat.size
}
if (sampleSize <= 0) return emptyArray()
val out = ArrayList<FloatArray>()
var offset = 0
while (offset < flat.size) {
val end = minOf(offset + sampleSize, flat.size)
out.add(flat.copyOfRange(offset, end))
offset = end
}
return out.toTypedArray()
}
private fun buildHeStreamPatternJson(streamId: Int, payload: String): String {
val trimmed = payload.trim()
if (trimmed.startsWith("{")) return trimmed
val escaped = payload.replace("\\", "\\\\").replace("\"", "\\\"")
return "{\"streamId\":$streamId,\"payload\":\"$escaped\"}"
}
suspend fun <T> callAsync(block: () -> T): OperationResult<T> = withContext(Dispatchers.IO) {
try {
val result = block()
@@ -25,6 +25,16 @@ class ComponentRegistry(
private var previousStates = emptyMap<ComponentType, ComponentState>()
private companion object {
const val MOTOR_TELEMETRY_FRESH_TIMEOUT_MS = 5000L
}
// Motor is physically downstream of the SmartBase Dongle.
// Keep the latest Dongle card decision from the same detection loop so Motor cannot remain green
// after the Dongle transport is disconnected, while preserving v4 Dongle detection behavior.
@Volatile
private var latestDongleCardStateForMotorGate: ComponentState = ComponentState.GREY
fun startDetection() {
scope.launch(Dispatchers.IO) {
while (isActive) {
@@ -95,11 +105,12 @@ class ComponentRegistry(
private fun dumpDebugOnce() {
if (debugDumped || !repo.isBound()) return
debugDumped = true
// 首页首次检测不要调用 getDebugSnapshotJson / camera / motor 等重 API。
// 这些 API 会触发 native snapshot 或 camera state 路径;RuntimeHost 刚启动时容易与
// native init/camera warm-start 形成 Binder 等待,导致 NOT_BOUND 或 service ANR。
val apis = mapOf(
"getCameraStateJson" to runCatching { repo.getCameraStateJson() }.getOrDefault("EXCEPTION"),
"getLatestMotorStateJson" to runCatching { repo.getLatestMotorStateJson() }.getOrDefault("EXCEPTION"),
"getDebugSnapshotJson(first200)" to runCatching { repo.getDebugSnapshotJson().take(200) }.getOrDefault("EXCEPTION"),
"getHandleStateJson" to runCatching { repo.getHandleStateJson() }.getOrDefault("EXCEPTION"),
"isBound" to repo.isBound().toString(),
"getDongleStateJson(first400)" to runCatching { repo.getDongleStateJson().take(400) }.getOrDefault("EXCEPTION")
)
for ((name, value) in apis) {
logger.log(LogEntry(
@@ -153,12 +164,32 @@ class ComponentRegistry(
}
private fun checkDongle(): ComponentState {
val state = computeDongleState()
latestDongleCardStateForMotorGate = state
return state
}
private fun computeDongleState(): ComponentState {
return try {
// Dongle 是 USB 外设,状态在 getDebugSnapshotJson 的全量数据中
val json = repo.getDebugSnapshotJson()
val json = repo.getDongleStateJson()
if (isUnavailable(json)) return ComponentState.GREY
// dongleState 字段存在说明 Dongle USB 已连接并被 RuntimeHost 识别
if (json.contains("\"dongleState\"")) {
val obj = JSONObject(json)
val transport = obj.optJSONObject("transport") ?: return ComponentState.GREY
val active = obj.optBoolean("active", false) || obj.optBoolean("available", false) || obj.optBoolean("connected", false)
val transportActive = transport.optBoolean("active", false) || transport.optBoolean("connected", false)
val usbBridgeRegistered = transport.optBoolean("usbBridgeRegistered", false)
val readLoopRunning = transport.optBoolean("readLoopRunning", false)
val usbPresent = transport.optBoolean("usbPresent", false)
val state = transport.optString("state", "")
// Dongle 首页卡片表达“USB transport 已经被 RuntimeHost 打开并注册”,
// 不要求 lastUsbInAgeMs < 3s。是否有上行数据滞后由 detail page 的 dataStale/lastUsbInAgeMs 显示。
if ((active || transportActive || state == "CONNECTED") &&
usbPresent &&
usbBridgeRegistered &&
readLoopRunning
) {
ComponentState.ACTIVE
} else {
ComponentState.GREY
@@ -170,11 +201,23 @@ class ComponentRegistry(
private fun checkMotor(): ComponentState {
return try {
// Motor is physically downstream of the SmartBase Dongle.
// V7 requires fresh motor telemetry; stale cached telemetry after Dongle re-plug must not turn Motor green.
if (latestDongleCardStateForMotorGate != ComponentState.ACTIVE) return ComponentState.GREY
val json = repo.getLatestMotorStateJson()
if (isUnavailable(json)) return ComponentState.GREY
// motor-runtime-state.v1 has top-level "available" field
val obj = JSONObject(json)
if (obj.optBoolean("available", false)) ComponentState.ACTIVE else ComponentState.GREY
if (obj.optString("contractVersion", "") != "kiwii.motor-runtime-state.v1") return ComponentState.GREY
if (obj.optBoolean("blockedByDongleTransport", false)) return ComponentState.GREY
if (obj.optBoolean("blockedByMotorTelemetry", false)) return ComponentState.GREY
if (!obj.optBoolean("available", false)) return ComponentState.GREY
val dataAgeMs = obj.optLong("dataAgeMs", Long.MAX_VALUE)
if (dataAgeMs < 0L || dataAgeMs > MOTOR_TELEMETRY_FRESH_TIMEOUT_MS) return ComponentState.GREY
ComponentState.ACTIVE
} catch (_: Exception) {
ComponentState.GREY
}
@@ -15,7 +15,7 @@ class AarProxyStateSource(private val bridge: BridgeRepository) : RuntimeStateSo
ComponentType.LEFT_HANDLE, ComponentType.RIGHT_HANDLE -> bridge.getHandleStateJson()
ComponentType.MOTOR -> bridge.getLatestMotorStateJson()
ComponentType.BALANCE_BOARD -> bridge.getDebugSnapshotJson()
ComponentType.DONGLE -> bridge.getDebugSnapshotJson()
ComponentType.DONGLE -> bridge.getDongleStateJson()
ComponentType.TELEMETRY_SAFETY -> bridge.getTelemetryStateJson()
ComponentType.SESSION_LOG -> "{}"
}
@@ -2,20 +2,44 @@ package com.kiwii.controlpanel.ui.components
import android.content.Context
import android.view.View
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.kiwii.controlpanel.R
import com.kiwii.controlpanel.model.ComponentType
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
class DongleOps(context: Context) : BaseOps(context) {
class DongleOps(
context: Context,
private val viewModel: ComponentDetailViewModel
) : BaseOps(context) {
override fun createView(): View {
val layout = verticalLayout()
layout.addView(TextView(context).apply {
text = "暂无 SDK API"
textSize = 12f
setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle))
setPadding(0, 32, 0, 32)
layout.addView(createSection("Dongle Transport"))
layout.addView(createButton("getDongleStateJson") {
viewModel.executeOperation(ComponentType.DONGLE, "getDongleStateJson") {
viewModel.bridgeRepo.getDongleStateJson()
}
})
layout.addView(createButton("getDebugSnapshotJson") {
viewModel.executeOperation(ComponentType.DONGLE, "getDebugSnapshotJson") {
viewModel.bridgeRepo.getDebugSnapshotJson()
}
})
layout.addView(createSection("Transport Probe"))
layout.addView(createButton("queryMotorTrainingMode") {
viewModel.executeOperation(ComponentType.DONGLE, "queryMotorTrainingMode", "axis=0") {
viewModel.bridgeRepo.queryMotorTrainingMode(0)
}
})
layout.addView(createSection("Expected fields"))
layout.addView(createButton("Inspect transport + slots") {
viewModel.executeOperation(ComponentType.DONGLE, "inspectDongleDiagnostics") {
viewModel.bridgeRepo.getDongleStateJson()
}
})
return layout
}
}
@@ -1,63 +1,208 @@
package com.kiwii.controlpanel.ui.components
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.InputType
import android.view.Gravity
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView
import com.kiwii.controlpanel.model.ComponentType
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
import org.json.JSONObject
class MotorOps(
context: Context,
private val viewModel: ComponentDetailViewModel
) : BaseOps(context) {
private val trainingModes = arrayOf(
"NONE (0)", "FREE_WEIGHT (1)", "ECCENTRIC_OVERLOAD (2)",
"VISCOUS (3)", "ELASTIC (4)", "ISOKINETIC_SPOTTING (5)", "SPOTTER (6)"
private data class ParamDef(
val key: String,
val label: String,
val defaultValue: String,
val unit: String = "",
val definition: String = ""
)
private data class ModeDef(
val mode: Int,
val name: String,
val description: String,
val params: List<ParamDef>
) {
override fun toString(): String = "$name ($mode)"
}
private val spotterParams = listOf(
ParamDef("fm_sp_v", "Spotter velocity threshold", "0.1", "m/s", "Spotter 触发速度阈值;低于该速度并满足时间/位置条件时进入保护减重逻辑。"),
ParamDef("fm_sp_t", "Spotter trigger time", "5.0", "s", "Spotter 触发时间阈值;持续满足触发条件超过该时间后启动保护。"),
ParamDef("fm_sp_d", "Spotter decay duration", "5.0", "s", "Spotter 减重衰减时长;保护模式下降低负重的时间尺度。"),
ParamDef("fm_sp_home", "Spotter home", "0.0", "m", "Spotter 参考初始位置;用于判断用户是否离开安全范围。"),
ParamDef("fm_sp_rng", "Spotter load range", "-0.10", "m", "Spotter 负重判定位置阈值;通常为负值,表示相对 home 的保护触发位置范围。"),
ParamDef("fm_sp_rec", "Spotter recovery", "1.0", "s", "Spotter 恢复时长;保护结束后恢复到目标负重的时间尺度。")
)
private val pulleyParam = ParamDef("fm_rad", "Pulley radius", "0.04", "m", "滑轮/卷筒有效半径;输出扭矩 = 输出力 × fm_rad。该值直接影响力和绳长估算。")
private val modes = listOf(
ModeDef(0, "None", "Output force is zero.", emptyList()),
ModeDef(1, "FreeWeight", "Free weight / inertia compensation.", listOf(
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;FreeWeight 下相当于目标训练重量,不等于独立拉力传感器测得值。"),
ParamDef("fm_kin", "Inertia ratio", "0.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"),
ParamDef("fm_bfr", "Linear friction", "0.0", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"),
pulleyParam
)),
ModeDef(2, "EccentricOverload", "Eccentric overload with smooth concentric/eccentric switching.", listOf(
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;EccentricOverload 下作为向心阶段基准目标负重。"),
ParamDef("fm_kin", "Inertia ratio", "1.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"),
ParamDef("fm_bfr", "Linear friction", "0.05", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"),
ParamDef("fm_kecc", "Eccentric multiplier", "1.5", "", "离心倍率;离心阶段目标力 = fm_kecc × 向心阶段目标力。"),
ParamDef("fm_vth", "Velocity threshold", "0.3", "m/s", "离心/向心平滑切换速度阈值;用于避免速度方向切换时力突变。"),
pulleyParam
) + spotterParams),
ModeDef(3, "Viscous", "Viscous / fluid resistance.", listOf(
ParamDef("fm_cdrv", "Drive damping", "0.1", "N/(m/s)^2", "粘滞拉出方向平方阻尼系数;拉出速度越大阻尼增长越快。"),
ParamDef("fm_crec", "Recovery damping", "0.1", "N/(m/s)", "粘滞回收方向线性阻尼系数;回收阶段按速度线性给阻尼。"),
pulleyParam
)),
ModeDef(4, "Elastic", "Elastic spring mode.", listOf(
ParamDef("fm_k", "Spring stiffness", "0.0", "N/m", "弹性刚度;输出力随位置偏移按弹簧模型变化。"),
ParamDef("fm_x0", "Zero position", "0.0", "m", "弹性零点位置;rope length 与该位置的差决定弹性力。"),
pulleyParam
)),
ModeDef(5, "IsokineticSpotting", "Isokinetic velocity wall plus spotting.", listOf(
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;作为等速保护和速度墙前的基础阻力。"),
ParamDef("fm_vmax", "Max velocity", "1.0", "m/s", "等速模式最大速度;超过该速度时由速度墙施加额外阻力。"),
ParamDef("fm_gwall", "Velocity wall gain", "0.0", "N/(m/s)", "等速速度墙增益;超出 fm_vmax 后按速度误差增加阻力。"),
pulleyParam
) + spotterParams),
ModeDef(6, "Spotter", "Spotter protection mode.", listOf(
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;Spotter 模式下作为保护逻辑的基础负重。"),
ParamDef("fm_kin", "Inertia ratio", "0.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"),
ParamDef("fm_bfr", "Linear friction", "0.0", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"),
pulleyParam
) + spotterParams)
)
private val signedDecimalInputType = InputType.TYPE_CLASS_NUMBER or
InputType.TYPE_NUMBER_FLAG_DECIMAL or
InputType.TYPE_NUMBER_FLAG_SIGNED
override fun createView(): View {
val layout = verticalLayout()
layout.addView(createSection("Weight"))
val weightInput = createParameterInput(
"Weight (kg)",
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL
)
val weightAction = createButton("setMotorWeightKg") {
val weight = weightInput.text.toString().toFloatOrNull() ?: 0f
viewModel.executeOperation(ComponentType.MOTOR, "setMotorWeightKg", "weight=$weight") {
viewModel.bridgeRepo.setMotorWeightKg(weight)
}
}
layout.addView(createParameterActionRow(weightAction, weightInput))
layout.addView(createSection("Realtime Telemetry"))
layout.addView(MotorRealtimeTelemetryPanel(context, viewModel.bridgeRepo))
layout.addView(createSection("Training Mode"))
layout.addView(createSection("Force Control Parameters"))
val modeSpinner = Spinner(context).apply {
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, trainingModes)
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, modes)
}
val paramInput = createParameterInput(
"Param (float)",
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL
)
val trainingModeAction = createButton("setMotorTrainingMode") {
val mode = modeSpinner.selectedItemPosition
val param = paramInput.text.toString().toFloatOrNull() ?: 0f
viewModel.executeOperation(ComponentType.MOTOR, "setMotorTrainingMode", "mode=$mode,param=$param") {
viewModel.bridgeRepo.setMotorTrainingMode(mode, param)
}
}
layout.addView(createParameterActionRow(trainingModeAction, modeSpinner, paramInput))
layout.addView(modeSpinner)
val axisInput = createParameterInput("Axis (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val queryModeAction = createButton("queryMotorTrainingMode") {
val axis = axisInput.text.toString().toIntOrNull() ?: 0
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "axis=$axis") {
viewModel.bridgeRepo.queryMotorTrainingMode(axis)
val description = TextView(context).apply {
textSize = 10f
setPadding(6.dp(), 4.dp(), 6.dp(), 4.dp())
}
layout.addView(description)
val paramContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
}
layout.addView(paramContainer)
val definitionText = TextView(context).apply {
textSize = 9.5f
setPadding(6.dp(), 4.dp(), 6.dp(), 8.dp())
}
layout.addView(definitionText)
val editTexts = LinkedHashMap<String, EditText>()
fun rebuildParamForm(mode: ModeDef) {
description.text = "Mode ${mode.mode}: ${mode.description}"
paramContainer.removeAllViews()
editTexts.clear()
if (mode.params.isEmpty()) {
val noneText = TextView(context).apply {
text = "fm_mode = 0. No extra parameters. Applying this mode sends {\"fm_mode\":0}."
textSize = 10f
typeface = Typeface.MONOSPACE
setPadding(6.dp(), 4.dp(), 6.dp(), 4.dp())
}
paramContainer.addView(noneText)
definitionText.text = "fm_mode: 力控模式。None = 0,输出力为 0。"
return
}
mode.params.chunked(2).forEach { rowParams ->
val rowViews = rowParams.map { param ->
createLabeledParameterInput(param).also { labeled ->
val editText = labeled.findViewWithTag<EditText>("input:${param.key}")
editTexts[param.key] = editText
}
}
paramContainer.addView(createParameterRow(*rowViews.toTypedArray()))
}
definitionText.text = buildString {
append("Parameter definitions:\n")
append("fm_mode: 力控模式,当前选择 ${mode.name} (${mode.mode})。\n")
mode.params.forEach { param ->
append(param.key)
append(": ")
append(if (param.definition.isNotBlank()) param.definition else param.label)
if (param.unit.isNotBlank()) {
append(" [")
append(param.unit)
append("]")
}
append('\n')
}
append("\nNote: fm_mset 是目标虚拟负重;实时 Force kgf 是由电机电流/扭矩估算的张力等效重量,不是第三方拉力传感器读数。")
}.trim()
}
modeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
rebuildParamForm(modes[position])
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
rebuildParamForm(modes.first())
val applyAction = createButton("setMotorForceControlParamsJson") {
val mode = modes[modeSpinner.selectedItemPosition]
val payload = JSONObject().apply {
put("fm_mode", mode.mode)
for ((key, editText) in editTexts) {
val value = editText.text.toString().trim().toDoubleOrNull()
if (value != null) put(key, value)
}
}.toString()
viewModel.executeOperation(ComponentType.MOTOR, "setMotorForceControlParamsJson", payload) {
viewModel.bridgeRepo.setMotorForceControlParamsJson(payload)
}
}
layout.addView(createParameterActionRow(queryModeAction, axisInput))
layout.addView(applyAction)
val queryCurrentAction = createButton("queryMotorTrainingMode current") {
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "mode=-1") {
viewModel.bridgeRepo.queryMotorTrainingMode(-1)
}
}
val querySelectedAction = createButton("queryMotorTrainingMode selected") {
val mode = modes[modeSpinner.selectedItemPosition]
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "mode=${mode.mode}") {
viewModel.bridgeRepo.queryMotorTrainingMode(mode.mode)
}
}
layout.addView(queryCurrentAction)
layout.addView(querySelectedAction)
layout.addView(createSection("State Queries"))
layout.addView(createButton("getLatestMotorStateJson") {
@@ -70,7 +215,47 @@ class MotorOps(
viewModel.bridgeRepo.getMotorControlStateJson()
}
})
layout.addView(createButton("getMotorDisconnectDiagnosisJson") {
viewModel.executeOperation(ComponentType.MOTOR, "getMotorDisconnectDiagnosisJson") {
viewModel.bridgeRepo.getMotorDisconnectDiagnosisJson()
}
})
layout.addView(createButton("sendMotorHeartbeat") {
viewModel.executeOperation(ComponentType.MOTOR, "sendMotorHeartbeat") {
viewModel.bridgeRepo.sendMotorHeartbeat()
}
})
return layout
}
private fun createLabeledParameterInput(param: ParamDef): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(2.dp(), 0, 2.dp(), 0)
addView(TextView(context).apply {
text = "${param.key} ="
textSize = 10.5f
typeface = Typeface.MONOSPACE
setTextColor(Color.rgb(38, 50, 56))
gravity = Gravity.CENTER_VERTICAL
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 0.95f))
addView(EditText(context).apply {
tag = "input:${param.key}"
inputType = signedDecimalInputType
setSingleLine(true)
setText(param.defaultValue)
hint = param.unit.ifBlank { param.label }
textSize = 11f
typeface = Typeface.MONOSPACE
setPadding(4.dp(), 0, 4.dp(), 0)
minHeight = 34.dp()
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1.05f))
}
}
private fun Int.dp(): Int = (this * context.resources.displayMetrics.density).toInt()
}
@@ -0,0 +1,146 @@
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.view.Gravity
import android.view.View
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
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)
private var executor: ScheduledExecutorService? = null
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 = "Polling RuntimeHost getLatestMotorStateJson() at 5 Hz"
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()
startPolling()
}
override fun onDetachedFromWindow() {
stopPolling()
super.onDetachedFromWindow()
}
private fun startPolling() {
if (executor != null) return
executor = Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "kiwii-motor-telemetry-panel").apply { isDaemon = true }
}.also { exec ->
exec.scheduleAtFixedRate({ pollOnce() }, 0L, 200L, TimeUnit.MILLISECONDS)
}
}
private fun stopPolling() {
executor?.shutdownNow()
executor = null
mainHandler.removeCallbacksAndMessages(null)
}
private fun pollOnce() {
if (!repo.isBound()) {
mainHandler.post { setUnavailable("RuntimeHost not bound") }
return
}
val json = try {
repo.getLatestMotorStateJson()
} catch (t: Throwable) {
mainHandler.post { setUnavailable(t.message ?: t.javaClass.simpleName) }
return
}
val sample = MotorTelemetryParser.parse(json)
mainHandler.post {
if (sample == null) {
setUnavailable("No fresh motor telemetry")
} else {
updateSample(sample)
}
}
}
private fun updateSample(sample: MotorTelemetrySample) {
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=${sample.dataAgeMs} ms"
chartView.addSample(sample)
}
private fun setUnavailable(reason: String) {
headline.text = "Force: — kgf Rope: — cm"
temperatureLine.text = "Temp: — °C"
subline.text = reason
}
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()
}
@@ -0,0 +1,83 @@
package com.kiwii.controlpanel.ui.components
import org.json.JSONObject
import kotlin.math.abs
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 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
}
}
@@ -0,0 +1,161 @@
package com.kiwii.controlpanel.ui.components
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import java.util.ArrayDeque
import kotlin.math.max
internal class MotorTelemetryScatterChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val samples = ArrayDeque<MotorTelemetrySample>()
private val forcePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(245, 124, 0)
style = Paint.Style.FILL
}
private val lengthPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(25, 118, 210)
style = Paint.Style.FILL
}
private val tempPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(198, 40, 40)
style = Paint.Style.FILL
}
private val axisPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(130, 145, 160)
strokeWidth = 1f.dp()
style = Paint.Style.STROKE
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(70, 130, 145, 160)
strokeWidth = 1f.dp()
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(38, 50, 56)
textSize = 10f.sp()
}
private val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(38, 50, 56)
textSize = 11f.sp()
isFakeBoldText = true
}
private val forceArea = RectF()
private val lengthArea = RectF()
private val tempArea = RectF()
private val visibleWindowMs = 30_000L
fun addSample(sample: MotorTelemetrySample) {
samples.addLast(sample)
val minTime = sample.wallTimeMs - visibleWindowMs
while (!samples.isEmpty() && samples.first.wallTimeMs < minTime) {
samples.removeFirst()
}
invalidate()
}
fun clear() {
samples.clear()
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val w = width.toFloat()
val h = height.toFloat()
if (w <= 0f || h <= 0f) return
val padL = 44f.dp()
val padR = 10f.dp()
val padT = 22f.dp()
val gap = 22f.dp()
val padB = 18f.dp()
val chartH = (h - padT - gap * 2f - padB) / 3f
forceArea.set(padL, padT, w - padR, padT + chartH)
lengthArea.set(padL, padT + chartH + gap, w - padR, padT + chartH + gap + chartH)
tempArea.set(padL, padT + chartH * 2f + gap * 2f, w - padR, padT + chartH * 2f + gap * 2f + chartH)
if (samples.isEmpty()) {
canvas.drawText("Waiting for fresh motor telemetry", padL, h / 2f, titlePaint)
return
}
val now = samples.last.wallTimeMs
val minT = now - visibleWindowMs
val forceMinMax = minMax(samples.map { it.forceKg })
val lengthMinMax = minMax(samples.map { it.ropeLengthM * 100.0 })
val tempMinMax = minMax(samples.map { it.tempC })
drawPanel(canvas, forceArea, "Force kgf", forceMinMax.first, forceMinMax.second)
drawPanel(canvas, lengthArea, "Rope length cm", lengthMinMax.first, lengthMinMax.second)
drawPanel(canvas, tempArea, "Temperature °C", tempMinMax.first, tempMinMax.second)
for (sample in samples) {
val x = xOf(sample.wallTimeMs, minT, now, forceArea)
canvas.drawCircle(x, yOf(sample.forceKg, forceMinMax.first, forceMinMax.second, forceArea), 2.1f.dp(), forcePaint)
canvas.drawCircle(x, yOf(sample.ropeLengthM * 100.0, lengthMinMax.first, lengthMinMax.second, lengthArea), 2.1f.dp(), lengthPaint)
canvas.drawCircle(x, yOf(sample.tempC, tempMinMax.first, tempMinMax.second, tempArea), 2.1f.dp(), tempPaint)
}
canvas.drawText("last 30 s", tempArea.right - 52f.dp(), h - 5f.dp(), textPaint)
}
private fun drawPanel(canvas: Canvas, area: RectF, label: String, minValue: Double, maxValue: Double) {
canvas.drawRect(area, axisPaint)
canvas.drawText(label, area.left, area.top - 6f.dp(), titlePaint)
val mid = area.top + area.height() / 2f
canvas.drawLine(area.left, mid, area.right, mid, gridPaint)
canvas.drawText(formatNumber(maxValue), 4f.dp(), area.top + 10f.dp(), textPaint)
canvas.drawText(formatNumber((minValue + maxValue) / 2.0), 4f.dp(), mid + 4f.dp(), textPaint)
canvas.drawText(formatNumber(minValue), 4f.dp(), area.bottom, textPaint)
}
private fun xOf(timeMs: Long, minT: Long, maxT: Long, area: RectF): Float {
val denom = max(1L, maxT - minT).toDouble()
val f = ((timeMs - minT).toDouble() / denom).coerceIn(0.0, 1.0)
return area.left + (area.width() * f).toFloat()
}
private fun yOf(value: Double, minValue: Double, maxValue: Double, area: RectF): Float {
val denom = max(1e-6, maxValue - minValue)
val f = ((value - minValue) / denom).coerceIn(0.0, 1.0)
return area.bottom - (area.height() * f).toFloat()
}
private fun minMax(values: List<Double>): Pair<Double, Double> {
val finite = values.filter { it.isFinite() }
if (finite.isEmpty()) return 0.0 to 1.0
var mn = finite.minOrNull() ?: 0.0
var mx = finite.maxOrNull() ?: 1.0
if (mx - mn < 1e-6) {
val base = max(1.0, kotlin.math.abs(mx))
mn -= base * 0.05
mx += base * 0.05
}
val span = mx - mn
mn -= span * 0.08
mx += span * 0.08
return mn to mx
}
private fun formatNumber(value: Double): String {
if (!value.isFinite()) return ""
val absValue = kotlin.math.abs(value)
return when {
absValue >= 100.0 -> value.toInt().toString()
absValue >= 10.0 -> String.format("%.1f", value)
else -> String.format("%.2f", value)
}
}
private fun Float.dp(): Float = this * resources.displayMetrics.density
private fun Float.sp(): Float = this * resources.displayMetrics.scaledDensity
}
@@ -8,8 +8,12 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -26,7 +30,9 @@ import com.kiwii.controlpanel.model.ComponentType
import com.kiwii.controlpanel.model.OperationResult
import com.kiwii.controlpanel.ui.components.*
import com.kiwii.controlpanel.util.JsonFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -41,7 +47,14 @@ class ComponentDetailFragment : Fragment() {
private lateinit var stateAdapter: StatAdapter
private lateinit var logAdapter: LogAdapter
private var latestStateText: String = "No state data"
private var latestResultText: String = ""
private var latestResultText: String = """
State Query Results will appear here.
For Motor, use getLatestMotorStateJson / getMotorDisconnectDiagnosisJson, or wait for auto disconnect diagnosis.
Left column is scrollable; scroll down to see the full result and Session Log.
""".trimIndent()
private var operationResultTextView: TextView? = null
private var lastAutoDisconnectDiagnosisTimestamp: Long = 0L
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -63,6 +76,8 @@ class ComponentDetailFragment : Fragment() {
setupMenu()
setupStateAndLog()
ensureStateQueryResultsPanel()
updateOperationResultText(latestResultText)
loadOpsView()
observeViewModel()
}
@@ -106,6 +121,21 @@ class ComponentDetailFragment : Fragment() {
binding.rvLogEntries.adapter = logAdapter
}
private fun ensureStateQueryResultsPanel() {
// v17 uses a static XML panel inside the left ScrollView. The left column is now
// scrollable like the right Controls column, and the State Query Results area has
// a large reserved height so long JSON / disconnect diagnosis remains visible.
operationResultTextView = binding.tvOperationResult
}
private fun updateOperationResultText(text: String) {
operationResultTextView?.text = text
}
private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density + 0.5f).toInt()
}
private fun loadOpsView() {
val opsView = when (componentType) {
ComponentType.RUNTIME_HOST_STATUS -> RuntimeHostStatusOps(requireContext(), viewModel)
@@ -113,7 +143,7 @@ class ComponentDetailFragment : Fragment() {
ComponentType.LEFT_HANDLE -> LeftHandleOps(requireContext(), viewModel)
ComponentType.RIGHT_HANDLE -> RightHandleOps(requireContext(), viewModel)
ComponentType.BALANCE_BOARD -> BalanceBoardOps(requireContext())
ComponentType.DONGLE -> DongleOps(requireContext())
ComponentType.DONGLE -> DongleOps(requireContext(), viewModel)
ComponentType.MOTOR -> MotorOps(requireContext(), viewModel)
ComponentType.TELEMETRY_SAFETY -> TelemetrySafetyOps(requireContext(), viewModel)
ComponentType.SESSION_LOG -> SessionLogOps(requireContext())
@@ -173,9 +203,10 @@ class ComponentDetailFragment : Fragment() {
latestResultText = "No Data"
}
null -> {
latestResultText = ""
// Keep the initial guidance text visible until the first explicit State Query result arrives.
}
}
updateOperationResultText(latestResultText)
}
}
@@ -195,9 +226,34 @@ class ComponentDetailFragment : Fragment() {
binding.rvLogEntries.scrollToPosition(visibleEntries.size - 1)
}
}
maybeAutoShowMotorDisconnectDiagnosis(visibleEntries)
}
}
private suspend fun maybeAutoShowMotorDisconnectDiagnosis(entries: List<LogEntry>) {
if (componentType != ComponentType.MOTOR) return
val event = entries.lastOrNull { entry ->
entry.component == ComponentType.MOTOR &&
entry.action == "availability_change" &&
(entry.result ?: "").contains("ACTIVE -> GREY")
} ?: return
if (event.timestamp <= lastAutoDisconnectDiagnosisTimestamp) return
lastAutoDisconnectDiagnosisTimestamp = event.timestamp
val diagnosis = withContext(Dispatchers.IO) {
viewModel.bridgeRepo.getMotorDisconnectDiagnosisJson()
}
latestResultText = JsonFormatter.format(diagnosis)
updateOperationResultText(latestResultText)
SessionLogger.log(LogEntry(
timestamp = System.currentTimeMillis(),
component = ComponentType.MOTOR,
action = "autoDisconnectDiagnosis",
params = "trigger=${event.result}",
result = diagnosis
))
}
private fun formatResultData(data: Any?): String {
return when (data) {
is String -> JsonFormatter.format(data)
@@ -220,6 +276,12 @@ class ComponentDetailFragment : Fragment() {
override fun onDestroyView() {
super.onDestroyView()
operationResultTextView = null
_binding = null
}
companion object {
private const val STATE_QUERY_PANEL_TAG = "state_query_results_panel_v15"
private const val STATE_QUERY_TEXT_TAG = "state_query_results_text_v15"
}
}
@@ -7,90 +7,139 @@
android:background="@drawable/bg_rhs_card"
android:padding="8dp">
<!-- Left: State + Log -->
<LinearLayout
<!-- Left: scrollable State + State Query Results + Session Log -->
<ScrollView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="45"
android:orientation="vertical"
android:layout_marginEnd="6dp">
android:layout_marginEnd="6dp"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<!-- State Card -->
<LinearLayout
android:id="@+id/left_scroll_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/bg_rhs_status_inner"
android:padding="8dp">
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_state_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:text="State"
android:textSize="12sp"
android:textStyle="bold"
android:textColor="@color/rhs_title" />
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_state_stats"
<!-- State Card -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/bg_rhs_status_inner"
android:padding="8dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_state_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:text="State"
android:textSize="12sp"
android:textStyle="bold"
android:textColor="@color/rhs_title" />
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_state_stats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:overScrollMode="never" />
<TextView
android:id="@+id/tv_state_updated_at"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="9sp"
android:fontFamily="monospace"
android:textColor="@color/rhs_timestamp"
android:layout_marginTop="4dp" />
</LinearLayout>
<!-- State Query Results / Disconnect Diagnosis -->
<LinearLayout
android:id="@+id/state_query_results_panel"
android:layout_width="match_parent"
android:layout_height="420dp"
android:orientation="vertical"
android:background="@drawable/bg_rhs_status_inner"
android:padding="6dp"
android:layout_marginTop="6dp"
android:overScrollMode="never" />
<TextView
android:id="@+id/tv_state_updated_at"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="9sp"
android:fontFamily="monospace"
android:textColor="@color/rhs_timestamp"
android:layout_marginTop="4dp" />
</LinearLayout>
<!-- Session Log -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:background="@drawable/bg_rhs_status_inner"
android:padding="6dp"
android:layout_marginTop="6dp"
android:minHeight="0dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp">
android:minHeight="360dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:text="Session Log"
android:text="State Query Results / Disconnect Diagnosis"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="@color/rhs_title" />
</RelativeLayout>
android:textColor="@color/rhs_title"
android:layout_marginBottom="4dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_log_entries"
<ScrollView
android:id="@+id/operation_result_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@drawable/bg_rhs_log_area"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:padding="6dp">
<TextView
android:id="@+id/tv_operation_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="State Query Results will appear here. For Motor, use getLatestMotorStateJson / getMotorDisconnectDiagnosisJson, or wait for auto disconnect diagnosis."
android:textSize="10sp"
android:fontFamily="monospace"
android:textColor="@color/rhs_stat_value"
android:textIsSelectable="true" />
</ScrollView>
</LinearLayout>
<!-- Session Log -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never" />
android:layout_height="260dp"
android:orientation="vertical"
android:background="@drawable/bg_rhs_status_inner"
android:padding="6dp"
android:layout_marginTop="6dp"
android:minHeight="180dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:text="Session Log"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="@color/rhs_title" />
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_log_entries"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="ifContentScrolls" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
<!-- Right: Controls -->
<LinearLayout