Add motor telemetry UI and disconnect diagnosis panel
This commit is contained in:
Binary file not shown.
@@ -58,9 +58,33 @@ class BridgeRepository {
|
|||||||
|
|
||||||
fun setMotorWeightKg(weight: Float): com.kiwii.bridge.MotorCommandResult = KiwiiRuntimeClientBridge.setMotorWeightKg(weight)
|
fun setMotorWeightKg(weight: Float): com.kiwii.bridge.MotorCommandResult = KiwiiRuntimeClientBridge.setMotorWeightKg(weight)
|
||||||
fun setMotorTrainingMode(mode: Int, param: Float): String = KiwiiRuntimeClientBridge.setMotorTrainingMode(mode, param)
|
fun setMotorTrainingMode(mode: Int, param: Float): String = KiwiiRuntimeClientBridge.setMotorTrainingMode(mode, param)
|
||||||
|
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 queryMotorTrainingMode(axis: Int): String = KiwiiRuntimeClientBridge.queryMotorTrainingMode(axis)
|
||||||
fun getLatestMotorStateJson(): String = KiwiiRuntimeClientBridge.getLatestMotorStateJson()
|
fun getLatestMotorStateJson(): String = KiwiiRuntimeClientBridge.getLatestMotorStateJson()
|
||||||
fun getMotorControlStateJson(): String = KiwiiRuntimeClientBridge.getMotorControlStateJson()
|
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 getTelemetryStateJson(): String = KiwiiRuntimeClientBridge.getTelemetryStateJson()
|
||||||
fun getSafetyStateJson(): String = KiwiiRuntimeClientBridge.getSafetyStateJson()
|
fun getSafetyStateJson(): String = KiwiiRuntimeClientBridge.getSafetyStateJson()
|
||||||
|
|||||||
@@ -1,63 +1,208 @@
|
|||||||
package com.kiwii.controlpanel.ui.components
|
package com.kiwii.controlpanel.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.InputType
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.AdapterView
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.Spinner
|
import android.widget.Spinner
|
||||||
|
import android.widget.TextView
|
||||||
import com.kiwii.controlpanel.model.ComponentType
|
import com.kiwii.controlpanel.model.ComponentType
|
||||||
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
class MotorOps(
|
class MotorOps(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val viewModel: ComponentDetailViewModel
|
private val viewModel: ComponentDetailViewModel
|
||||||
) : BaseOps(context) {
|
) : BaseOps(context) {
|
||||||
|
|
||||||
private val trainingModes = arrayOf(
|
private data class ParamDef(
|
||||||
"NONE (0)", "FREE_WEIGHT (1)", "ECCENTRIC_OVERLOAD (2)",
|
val key: String,
|
||||||
"VISCOUS (3)", "ELASTIC (4)", "ISOKINETIC_SPOTTING (5)", "SPOTTER (6)"
|
val label: String,
|
||||||
|
val defaultValue: String,
|
||||||
|
val unit: String = "",
|
||||||
|
val definition: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ModeDef(
|
||||||
|
val mode: Int,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val params: List<ParamDef>
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "$name ($mode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val spotterParams = listOf(
|
||||||
|
ParamDef("fm_sp_v", "Spotter velocity threshold", "0.1", "m/s", "Spotter 触发速度阈值;低于该速度并满足时间/位置条件时进入保护减重逻辑。"),
|
||||||
|
ParamDef("fm_sp_t", "Spotter trigger time", "5.0", "s", "Spotter 触发时间阈值;持续满足触发条件超过该时间后启动保护。"),
|
||||||
|
ParamDef("fm_sp_d", "Spotter decay duration", "5.0", "s", "Spotter 减重衰减时长;保护模式下降低负重的时间尺度。"),
|
||||||
|
ParamDef("fm_sp_home", "Spotter home", "0.0", "m", "Spotter 参考初始位置;用于判断用户是否离开安全范围。"),
|
||||||
|
ParamDef("fm_sp_rng", "Spotter load range", "-0.10", "m", "Spotter 负重判定位置阈值;通常为负值,表示相对 home 的保护触发位置范围。"),
|
||||||
|
ParamDef("fm_sp_rec", "Spotter recovery", "1.0", "s", "Spotter 恢复时长;保护结束后恢复到目标负重的时间尺度。")
|
||||||
|
)
|
||||||
|
|
||||||
|
private val pulleyParam = ParamDef("fm_rad", "Pulley radius", "0.04", "m", "滑轮/卷筒有效半径;输出扭矩 = 输出力 × fm_rad。该值直接影响力和绳长估算。")
|
||||||
|
|
||||||
|
private val modes = listOf(
|
||||||
|
ModeDef(0, "None", "Output force is zero.", emptyList()),
|
||||||
|
ModeDef(1, "FreeWeight", "Free weight / inertia compensation.", listOf(
|
||||||
|
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;FreeWeight 下相当于目标训练重量,不等于独立拉力传感器测得值。"),
|
||||||
|
ParamDef("fm_kin", "Inertia ratio", "0.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"),
|
||||||
|
ParamDef("fm_bfr", "Linear friction", "0.0", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"),
|
||||||
|
pulleyParam
|
||||||
|
)),
|
||||||
|
ModeDef(2, "EccentricOverload", "Eccentric overload with smooth concentric/eccentric switching.", listOf(
|
||||||
|
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;EccentricOverload 下作为向心阶段基准目标负重。"),
|
||||||
|
ParamDef("fm_kin", "Inertia ratio", "1.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"),
|
||||||
|
ParamDef("fm_bfr", "Linear friction", "0.05", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"),
|
||||||
|
ParamDef("fm_kecc", "Eccentric multiplier", "1.5", "", "离心倍率;离心阶段目标力 = fm_kecc × 向心阶段目标力。"),
|
||||||
|
ParamDef("fm_vth", "Velocity threshold", "0.3", "m/s", "离心/向心平滑切换速度阈值;用于避免速度方向切换时力突变。"),
|
||||||
|
pulleyParam
|
||||||
|
) + spotterParams),
|
||||||
|
ModeDef(3, "Viscous", "Viscous / fluid resistance.", listOf(
|
||||||
|
ParamDef("fm_cdrv", "Drive damping", "0.1", "N/(m/s)^2", "粘滞拉出方向平方阻尼系数;拉出速度越大阻尼增长越快。"),
|
||||||
|
ParamDef("fm_crec", "Recovery damping", "0.1", "N/(m/s)", "粘滞回收方向线性阻尼系数;回收阶段按速度线性给阻尼。"),
|
||||||
|
pulleyParam
|
||||||
|
)),
|
||||||
|
ModeDef(4, "Elastic", "Elastic spring mode.", listOf(
|
||||||
|
ParamDef("fm_k", "Spring stiffness", "0.0", "N/m", "弹性刚度;输出力随位置偏移按弹簧模型变化。"),
|
||||||
|
ParamDef("fm_x0", "Zero position", "0.0", "m", "弹性零点位置;rope length 与该位置的差决定弹性力。"),
|
||||||
|
pulleyParam
|
||||||
|
)),
|
||||||
|
ModeDef(5, "IsokineticSpotting", "Isokinetic velocity wall plus spotting.", listOf(
|
||||||
|
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;作为等速保护和速度墙前的基础阻力。"),
|
||||||
|
ParamDef("fm_vmax", "Max velocity", "1.0", "m/s", "等速模式最大速度;超过该速度时由速度墙施加额外阻力。"),
|
||||||
|
ParamDef("fm_gwall", "Velocity wall gain", "0.0", "N/(m/s)", "等速速度墙增益;超出 fm_vmax 后按速度误差增加阻力。"),
|
||||||
|
pulleyParam
|
||||||
|
) + spotterParams),
|
||||||
|
ModeDef(6, "Spotter", "Spotter protection mode.", listOf(
|
||||||
|
ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;Spotter 模式下作为保护逻辑的基础负重。"),
|
||||||
|
ParamDef("fm_kin", "Inertia ratio", "0.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"),
|
||||||
|
ParamDef("fm_bfr", "Linear friction", "0.0", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"),
|
||||||
|
pulleyParam
|
||||||
|
) + spotterParams)
|
||||||
|
)
|
||||||
|
|
||||||
|
private val signedDecimalInputType = InputType.TYPE_CLASS_NUMBER or
|
||||||
|
InputType.TYPE_NUMBER_FLAG_DECIMAL or
|
||||||
|
InputType.TYPE_NUMBER_FLAG_SIGNED
|
||||||
|
|
||||||
override fun createView(): View {
|
override fun createView(): View {
|
||||||
val layout = verticalLayout()
|
val layout = verticalLayout()
|
||||||
|
|
||||||
layout.addView(createSection("Weight"))
|
layout.addView(createSection("Realtime Telemetry"))
|
||||||
val weightInput = createParameterInput(
|
layout.addView(MotorRealtimeTelemetryPanel(context, viewModel.bridgeRepo))
|
||||||
"Weight (kg)",
|
|
||||||
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL
|
|
||||||
)
|
|
||||||
val weightAction = createButton("setMotorWeightKg") {
|
|
||||||
val weight = weightInput.text.toString().toFloatOrNull() ?: 0f
|
|
||||||
viewModel.executeOperation(ComponentType.MOTOR, "setMotorWeightKg", "weight=$weight") {
|
|
||||||
viewModel.bridgeRepo.setMotorWeightKg(weight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
layout.addView(createParameterActionRow(weightAction, weightInput))
|
|
||||||
|
|
||||||
layout.addView(createSection("Training Mode"))
|
layout.addView(createSection("Force Control Parameters"))
|
||||||
val modeSpinner = Spinner(context).apply {
|
val modeSpinner = Spinner(context).apply {
|
||||||
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, trainingModes)
|
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, modes)
|
||||||
}
|
}
|
||||||
val paramInput = createParameterInput(
|
layout.addView(modeSpinner)
|
||||||
"Param (float)",
|
|
||||||
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL
|
|
||||||
)
|
|
||||||
val trainingModeAction = createButton("setMotorTrainingMode") {
|
|
||||||
val mode = modeSpinner.selectedItemPosition
|
|
||||||
val param = paramInput.text.toString().toFloatOrNull() ?: 0f
|
|
||||||
viewModel.executeOperation(ComponentType.MOTOR, "setMotorTrainingMode", "mode=$mode,param=$param") {
|
|
||||||
viewModel.bridgeRepo.setMotorTrainingMode(mode, param)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
layout.addView(createParameterActionRow(trainingModeAction, modeSpinner, paramInput))
|
|
||||||
|
|
||||||
val axisInput = createParameterInput("Axis (int)", android.text.InputType.TYPE_CLASS_NUMBER)
|
val description = TextView(context).apply {
|
||||||
val queryModeAction = createButton("queryMotorTrainingMode") {
|
textSize = 10f
|
||||||
val axis = axisInput.text.toString().toIntOrNull() ?: 0
|
setPadding(6.dp(), 4.dp(), 6.dp(), 4.dp())
|
||||||
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "axis=$axis") {
|
}
|
||||||
viewModel.bridgeRepo.queryMotorTrainingMode(axis)
|
layout.addView(description)
|
||||||
|
|
||||||
|
val paramContainer = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
}
|
||||||
|
layout.addView(paramContainer)
|
||||||
|
|
||||||
|
val definitionText = TextView(context).apply {
|
||||||
|
textSize = 9.5f
|
||||||
|
setPadding(6.dp(), 4.dp(), 6.dp(), 8.dp())
|
||||||
|
}
|
||||||
|
layout.addView(definitionText)
|
||||||
|
|
||||||
|
val editTexts = LinkedHashMap<String, EditText>()
|
||||||
|
fun rebuildParamForm(mode: ModeDef) {
|
||||||
|
description.text = "Mode ${mode.mode}: ${mode.description}"
|
||||||
|
paramContainer.removeAllViews()
|
||||||
|
editTexts.clear()
|
||||||
|
|
||||||
|
if (mode.params.isEmpty()) {
|
||||||
|
val noneText = TextView(context).apply {
|
||||||
|
text = "fm_mode = 0. No extra parameters. Applying this mode sends {\"fm_mode\":0}."
|
||||||
|
textSize = 10f
|
||||||
|
typeface = Typeface.MONOSPACE
|
||||||
|
setPadding(6.dp(), 4.dp(), 6.dp(), 4.dp())
|
||||||
|
}
|
||||||
|
paramContainer.addView(noneText)
|
||||||
|
definitionText.text = "fm_mode: 力控模式。None = 0,输出力为 0。"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode.params.chunked(2).forEach { rowParams ->
|
||||||
|
val rowViews = rowParams.map { param ->
|
||||||
|
createLabeledParameterInput(param).also { labeled ->
|
||||||
|
val editText = labeled.findViewWithTag<EditText>("input:${param.key}")
|
||||||
|
editTexts[param.key] = editText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paramContainer.addView(createParameterRow(*rowViews.toTypedArray()))
|
||||||
|
}
|
||||||
|
definitionText.text = buildString {
|
||||||
|
append("Parameter definitions:\n")
|
||||||
|
append("fm_mode: 力控模式,当前选择 ${mode.name} (${mode.mode})。\n")
|
||||||
|
mode.params.forEach { param ->
|
||||||
|
append(param.key)
|
||||||
|
append(": ")
|
||||||
|
append(if (param.definition.isNotBlank()) param.definition else param.label)
|
||||||
|
if (param.unit.isNotBlank()) {
|
||||||
|
append(" [")
|
||||||
|
append(param.unit)
|
||||||
|
append("]")
|
||||||
|
}
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
append("\nNote: fm_mset 是目标虚拟负重;实时 Force kgf 是由电机电流/扭矩估算的张力等效重量,不是第三方拉力传感器读数。")
|
||||||
|
}.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
modeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
rebuildParamForm(modes[position])
|
||||||
|
}
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
|
}
|
||||||
|
rebuildParamForm(modes.first())
|
||||||
|
|
||||||
|
val applyAction = createButton("setMotorForceControlParamsJson") {
|
||||||
|
val mode = modes[modeSpinner.selectedItemPosition]
|
||||||
|
val payload = JSONObject().apply {
|
||||||
|
put("fm_mode", mode.mode)
|
||||||
|
for ((key, editText) in editTexts) {
|
||||||
|
val value = editText.text.toString().trim().toDoubleOrNull()
|
||||||
|
if (value != null) put(key, value)
|
||||||
|
}
|
||||||
|
}.toString()
|
||||||
|
viewModel.executeOperation(ComponentType.MOTOR, "setMotorForceControlParamsJson", payload) {
|
||||||
|
viewModel.bridgeRepo.setMotorForceControlParamsJson(payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
layout.addView(createParameterActionRow(queryModeAction, axisInput))
|
layout.addView(applyAction)
|
||||||
|
|
||||||
|
val queryCurrentAction = createButton("queryMotorTrainingMode current") {
|
||||||
|
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "mode=-1") {
|
||||||
|
viewModel.bridgeRepo.queryMotorTrainingMode(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val querySelectedAction = createButton("queryMotorTrainingMode selected") {
|
||||||
|
val mode = modes[modeSpinner.selectedItemPosition]
|
||||||
|
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "mode=${mode.mode}") {
|
||||||
|
viewModel.bridgeRepo.queryMotorTrainingMode(mode.mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout.addView(queryCurrentAction)
|
||||||
|
layout.addView(querySelectedAction)
|
||||||
|
|
||||||
layout.addView(createSection("State Queries"))
|
layout.addView(createSection("State Queries"))
|
||||||
layout.addView(createButton("getLatestMotorStateJson") {
|
layout.addView(createButton("getLatestMotorStateJson") {
|
||||||
@@ -70,7 +215,47 @@ class MotorOps(
|
|||||||
viewModel.bridgeRepo.getMotorControlStateJson()
|
viewModel.bridgeRepo.getMotorControlStateJson()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
layout.addView(createButton("getMotorDisconnectDiagnosisJson") {
|
||||||
|
viewModel.executeOperation(ComponentType.MOTOR, "getMotorDisconnectDiagnosisJson") {
|
||||||
|
viewModel.bridgeRepo.getMotorDisconnectDiagnosisJson()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
layout.addView(createButton("sendMotorHeartbeat") {
|
||||||
|
viewModel.executeOperation(ComponentType.MOTOR, "sendMotorHeartbeat") {
|
||||||
|
viewModel.bridgeRepo.sendMotorHeartbeat()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return layout
|
return layout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createLabeledParameterInput(param: ParamDef): LinearLayout {
|
||||||
|
return LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
setPadding(2.dp(), 0, 2.dp(), 0)
|
||||||
|
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = "${param.key} ="
|
||||||
|
textSize = 10.5f
|
||||||
|
typeface = Typeface.MONOSPACE
|
||||||
|
setTextColor(Color.rgb(38, 50, 56))
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 0.95f))
|
||||||
|
|
||||||
|
addView(EditText(context).apply {
|
||||||
|
tag = "input:${param.key}"
|
||||||
|
inputType = signedDecimalInputType
|
||||||
|
setSingleLine(true)
|
||||||
|
setText(param.defaultValue)
|
||||||
|
hint = param.unit.ifBlank { param.label }
|
||||||
|
textSize = 11f
|
||||||
|
typeface = Typeface.MONOSPACE
|
||||||
|
setPadding(4.dp(), 0, 4.dp(), 0)
|
||||||
|
minHeight = 34.dp()
|
||||||
|
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1.05f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.dp(): Int = (this * context.resources.displayMetrics.density).toInt()
|
||||||
}
|
}
|
||||||
|
|||||||
+146
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
package com.kiwii.controlpanel.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import java.util.ArrayDeque
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
internal class MotorTelemetryScatterChartView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : View(context, attrs) {
|
||||||
|
|
||||||
|
private val samples = ArrayDeque<MotorTelemetrySample>()
|
||||||
|
private val forcePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.rgb(245, 124, 0)
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
private val lengthPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.rgb(25, 118, 210)
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
private val tempPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.rgb(198, 40, 40)
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
private val axisPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.rgb(130, 145, 160)
|
||||||
|
strokeWidth = 1f.dp()
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
}
|
||||||
|
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.argb(70, 130, 145, 160)
|
||||||
|
strokeWidth = 1f.dp()
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
}
|
||||||
|
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.rgb(38, 50, 56)
|
||||||
|
textSize = 10f.sp()
|
||||||
|
}
|
||||||
|
private val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.rgb(38, 50, 56)
|
||||||
|
textSize = 11f.sp()
|
||||||
|
isFakeBoldText = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val forceArea = RectF()
|
||||||
|
private val lengthArea = RectF()
|
||||||
|
private val tempArea = RectF()
|
||||||
|
private val visibleWindowMs = 30_000L
|
||||||
|
|
||||||
|
fun addSample(sample: MotorTelemetrySample) {
|
||||||
|
samples.addLast(sample)
|
||||||
|
val minTime = sample.wallTimeMs - visibleWindowMs
|
||||||
|
while (!samples.isEmpty() && samples.first.wallTimeMs < minTime) {
|
||||||
|
samples.removeFirst()
|
||||||
|
}
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
samples.clear()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
val w = width.toFloat()
|
||||||
|
val h = height.toFloat()
|
||||||
|
if (w <= 0f || h <= 0f) return
|
||||||
|
|
||||||
|
val padL = 44f.dp()
|
||||||
|
val padR = 10f.dp()
|
||||||
|
val padT = 22f.dp()
|
||||||
|
val gap = 22f.dp()
|
||||||
|
val padB = 18f.dp()
|
||||||
|
val chartH = (h - padT - gap * 2f - padB) / 3f
|
||||||
|
forceArea.set(padL, padT, w - padR, padT + chartH)
|
||||||
|
lengthArea.set(padL, padT + chartH + gap, w - padR, padT + chartH + gap + chartH)
|
||||||
|
tempArea.set(padL, padT + chartH * 2f + gap * 2f, w - padR, padT + chartH * 2f + gap * 2f + chartH)
|
||||||
|
|
||||||
|
if (samples.isEmpty()) {
|
||||||
|
canvas.drawText("Waiting for fresh motor telemetry", padL, h / 2f, titlePaint)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = samples.last.wallTimeMs
|
||||||
|
val minT = now - visibleWindowMs
|
||||||
|
val forceMinMax = minMax(samples.map { it.forceKg })
|
||||||
|
val lengthMinMax = minMax(samples.map { it.ropeLengthM * 100.0 })
|
||||||
|
val tempMinMax = minMax(samples.map { it.tempC })
|
||||||
|
|
||||||
|
drawPanel(canvas, forceArea, "Force kgf", forceMinMax.first, forceMinMax.second)
|
||||||
|
drawPanel(canvas, lengthArea, "Rope length cm", lengthMinMax.first, lengthMinMax.second)
|
||||||
|
drawPanel(canvas, tempArea, "Temperature °C", tempMinMax.first, tempMinMax.second)
|
||||||
|
|
||||||
|
for (sample in samples) {
|
||||||
|
val x = xOf(sample.wallTimeMs, minT, now, forceArea)
|
||||||
|
canvas.drawCircle(x, yOf(sample.forceKg, forceMinMax.first, forceMinMax.second, forceArea), 2.1f.dp(), forcePaint)
|
||||||
|
canvas.drawCircle(x, yOf(sample.ropeLengthM * 100.0, lengthMinMax.first, lengthMinMax.second, lengthArea), 2.1f.dp(), lengthPaint)
|
||||||
|
canvas.drawCircle(x, yOf(sample.tempC, tempMinMax.first, tempMinMax.second, tempArea), 2.1f.dp(), tempPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawText("last 30 s", tempArea.right - 52f.dp(), h - 5f.dp(), textPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawPanel(canvas: Canvas, area: RectF, label: String, minValue: Double, maxValue: Double) {
|
||||||
|
canvas.drawRect(area, axisPaint)
|
||||||
|
canvas.drawText(label, area.left, area.top - 6f.dp(), titlePaint)
|
||||||
|
val mid = area.top + area.height() / 2f
|
||||||
|
canvas.drawLine(area.left, mid, area.right, mid, gridPaint)
|
||||||
|
canvas.drawText(formatNumber(maxValue), 4f.dp(), area.top + 10f.dp(), textPaint)
|
||||||
|
canvas.drawText(formatNumber((minValue + maxValue) / 2.0), 4f.dp(), mid + 4f.dp(), textPaint)
|
||||||
|
canvas.drawText(formatNumber(minValue), 4f.dp(), area.bottom, textPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun xOf(timeMs: Long, minT: Long, maxT: Long, area: RectF): Float {
|
||||||
|
val denom = max(1L, maxT - minT).toDouble()
|
||||||
|
val f = ((timeMs - minT).toDouble() / denom).coerceIn(0.0, 1.0)
|
||||||
|
return area.left + (area.width() * f).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun yOf(value: Double, minValue: Double, maxValue: Double, area: RectF): Float {
|
||||||
|
val denom = max(1e-6, maxValue - minValue)
|
||||||
|
val f = ((value - minValue) / denom).coerceIn(0.0, 1.0)
|
||||||
|
return area.bottom - (area.height() * f).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun minMax(values: List<Double>): Pair<Double, Double> {
|
||||||
|
val finite = values.filter { it.isFinite() }
|
||||||
|
if (finite.isEmpty()) return 0.0 to 1.0
|
||||||
|
var mn = finite.minOrNull() ?: 0.0
|
||||||
|
var mx = finite.maxOrNull() ?: 1.0
|
||||||
|
if (mx - mn < 1e-6) {
|
||||||
|
val base = max(1.0, kotlin.math.abs(mx))
|
||||||
|
mn -= base * 0.05
|
||||||
|
mx += base * 0.05
|
||||||
|
}
|
||||||
|
val span = mx - mn
|
||||||
|
mn -= span * 0.08
|
||||||
|
mx += span * 0.08
|
||||||
|
return mn to mx
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatNumber(value: Double): String {
|
||||||
|
if (!value.isFinite()) return "—"
|
||||||
|
val absValue = kotlin.math.abs(value)
|
||||||
|
return when {
|
||||||
|
absValue >= 100.0 -> value.toInt().toString()
|
||||||
|
absValue >= 10.0 -> String.format("%.1f", value)
|
||||||
|
else -> String.format("%.2f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Float.dp(): Float = this * resources.displayMetrics.density
|
||||||
|
private fun Float.sp(): Float = this * resources.displayMetrics.scaledDensity
|
||||||
|
}
|
||||||
@@ -8,8 +8,12 @@ import android.view.MenuItem
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
@@ -26,7 +30,9 @@ import com.kiwii.controlpanel.model.ComponentType
|
|||||||
import com.kiwii.controlpanel.model.OperationResult
|
import com.kiwii.controlpanel.model.OperationResult
|
||||||
import com.kiwii.controlpanel.ui.components.*
|
import com.kiwii.controlpanel.ui.components.*
|
||||||
import com.kiwii.controlpanel.util.JsonFormatter
|
import com.kiwii.controlpanel.util.JsonFormatter
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -41,7 +47,14 @@ class ComponentDetailFragment : Fragment() {
|
|||||||
private lateinit var stateAdapter: StatAdapter
|
private lateinit var stateAdapter: StatAdapter
|
||||||
private lateinit var logAdapter: LogAdapter
|
private lateinit var logAdapter: LogAdapter
|
||||||
private var latestStateText: String = "No state data"
|
private var latestStateText: String = "No state data"
|
||||||
private var latestResultText: String = "—"
|
private var latestResultText: String = """
|
||||||
|
State Query Results will appear here.
|
||||||
|
For Motor, use getLatestMotorStateJson / getMotorDisconnectDiagnosisJson, or wait for auto disconnect diagnosis.
|
||||||
|
|
||||||
|
Left column is scrollable; scroll down to see the full result and Session Log.
|
||||||
|
""".trimIndent()
|
||||||
|
private var operationResultTextView: TextView? = null
|
||||||
|
private var lastAutoDisconnectDiagnosisTimestamp: Long = 0L
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -63,6 +76,8 @@ class ComponentDetailFragment : Fragment() {
|
|||||||
|
|
||||||
setupMenu()
|
setupMenu()
|
||||||
setupStateAndLog()
|
setupStateAndLog()
|
||||||
|
ensureStateQueryResultsPanel()
|
||||||
|
updateOperationResultText(latestResultText)
|
||||||
loadOpsView()
|
loadOpsView()
|
||||||
observeViewModel()
|
observeViewModel()
|
||||||
}
|
}
|
||||||
@@ -106,6 +121,21 @@ class ComponentDetailFragment : Fragment() {
|
|||||||
binding.rvLogEntries.adapter = logAdapter
|
binding.rvLogEntries.adapter = logAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ensureStateQueryResultsPanel() {
|
||||||
|
// v17 uses a static XML panel inside the left ScrollView. The left column is now
|
||||||
|
// scrollable like the right Controls column, and the State Query Results area has
|
||||||
|
// a large reserved height so long JSON / disconnect diagnosis remains visible.
|
||||||
|
operationResultTextView = binding.tvOperationResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateOperationResultText(text: String) {
|
||||||
|
operationResultTextView?.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dp(value: Int): Int {
|
||||||
|
return (value * resources.displayMetrics.density + 0.5f).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadOpsView() {
|
private fun loadOpsView() {
|
||||||
val opsView = when (componentType) {
|
val opsView = when (componentType) {
|
||||||
ComponentType.RUNTIME_HOST_STATUS -> RuntimeHostStatusOps(requireContext(), viewModel)
|
ComponentType.RUNTIME_HOST_STATUS -> RuntimeHostStatusOps(requireContext(), viewModel)
|
||||||
@@ -173,9 +203,10 @@ class ComponentDetailFragment : Fragment() {
|
|||||||
latestResultText = "No Data"
|
latestResultText = "No Data"
|
||||||
}
|
}
|
||||||
null -> {
|
null -> {
|
||||||
latestResultText = "—"
|
// Keep the initial guidance text visible until the first explicit State Query result arrives.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateOperationResultText(latestResultText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +226,34 @@ class ComponentDetailFragment : Fragment() {
|
|||||||
binding.rvLogEntries.scrollToPosition(visibleEntries.size - 1)
|
binding.rvLogEntries.scrollToPosition(visibleEntries.size - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maybeAutoShowMotorDisconnectDiagnosis(visibleEntries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun maybeAutoShowMotorDisconnectDiagnosis(entries: List<LogEntry>) {
|
||||||
|
if (componentType != ComponentType.MOTOR) return
|
||||||
|
val event = entries.lastOrNull { entry ->
|
||||||
|
entry.component == ComponentType.MOTOR &&
|
||||||
|
entry.action == "availability_change" &&
|
||||||
|
(entry.result ?: "").contains("ACTIVE -> GREY")
|
||||||
|
} ?: return
|
||||||
|
if (event.timestamp <= lastAutoDisconnectDiagnosisTimestamp) return
|
||||||
|
|
||||||
|
lastAutoDisconnectDiagnosisTimestamp = event.timestamp
|
||||||
|
val diagnosis = withContext(Dispatchers.IO) {
|
||||||
|
viewModel.bridgeRepo.getMotorDisconnectDiagnosisJson()
|
||||||
|
}
|
||||||
|
latestResultText = JsonFormatter.format(diagnosis)
|
||||||
|
updateOperationResultText(latestResultText)
|
||||||
|
SessionLogger.log(LogEntry(
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
component = ComponentType.MOTOR,
|
||||||
|
action = "autoDisconnectDiagnosis",
|
||||||
|
params = "trigger=${event.result}",
|
||||||
|
result = diagnosis
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatResultData(data: Any?): String {
|
private fun formatResultData(data: Any?): String {
|
||||||
return when (data) {
|
return when (data) {
|
||||||
is String -> JsonFormatter.format(data)
|
is String -> JsonFormatter.format(data)
|
||||||
@@ -220,6 +276,12 @@ class ComponentDetailFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
operationResultTextView = null
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val STATE_QUERY_PANEL_TAG = "state_query_results_panel_v15"
|
||||||
|
private const val STATE_QUERY_TEXT_TAG = "state_query_results_text_v15"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,90 +7,139 @@
|
|||||||
android:background="@drawable/bg_rhs_card"
|
android:background="@drawable/bg_rhs_card"
|
||||||
android:padding="8dp">
|
android:padding="8dp">
|
||||||
|
|
||||||
<!-- Left: State + Log -->
|
<!-- Left: scrollable State + State Query Results + Session Log -->
|
||||||
<LinearLayout
|
<ScrollView
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="45"
|
android:layout_weight="45"
|
||||||
android:orientation="vertical"
|
android:layout_marginEnd="6dp"
|
||||||
android:layout_marginEnd="6dp">
|
android:fillViewport="true"
|
||||||
|
android:overScrollMode="ifContentScrolls">
|
||||||
|
|
||||||
<!-- State Card -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/left_scroll_content"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical">
|
||||||
android:background="@drawable/bg_rhs_status_inner"
|
|
||||||
android:padding="8dp">
|
|
||||||
|
|
||||||
<RelativeLayout
|
<!-- State Card -->
|
||||||
android:layout_width="match_parent"
|
<LinearLayout
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_state_title"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_alignParentStart="true"
|
|
||||||
android:layout_centerVertical="true"
|
|
||||||
android:text="State"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@color/rhs_title" />
|
|
||||||
</RelativeLayout>
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/rv_state_stats"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/bg_rhs_status_inner"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_state_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:text="State"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/rhs_title" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rv_state_stats"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:overScrollMode="never" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_state_updated_at"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/rhs_timestamp"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- State Query Results / Disconnect Diagnosis -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/state_query_results_panel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="420dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/bg_rhs_status_inner"
|
||||||
|
android:padding="6dp"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:overScrollMode="never" />
|
android:minHeight="360dp">
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_state_updated_at"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="9sp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:textColor="@color/rhs_timestamp"
|
|
||||||
android:layout_marginTop="4dp" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- Session Log -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/bg_rhs_status_inner"
|
|
||||||
android:padding="6dp"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:minHeight="0dp">
|
|
||||||
|
|
||||||
<RelativeLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="4dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentStart="true"
|
android:text="State Query Results / Disconnect Diagnosis"
|
||||||
android:layout_centerVertical="true"
|
|
||||||
android:text="Session Log"
|
|
||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/rhs_title" />
|
android:textColor="@color/rhs_title"
|
||||||
</RelativeLayout>
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<ScrollView
|
||||||
android:id="@+id/rv_log_entries"
|
android:id="@+id/operation_result_scroll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/bg_rhs_log_area"
|
||||||
|
android:fillViewport="true"
|
||||||
|
android:overScrollMode="ifContentScrolls"
|
||||||
|
android:padding="6dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_operation_result"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="State Query Results will appear here. For Motor, use getLatestMotorStateJson / getMotorDisconnectDiagnosisJson, or wait for auto disconnect diagnosis."
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/rhs_stat_value"
|
||||||
|
android:textIsSelectable="true" />
|
||||||
|
</ScrollView>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Session Log -->
|
||||||
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="260dp"
|
||||||
android:layout_weight="1"
|
android:orientation="vertical"
|
||||||
android:overScrollMode="never" />
|
android:background="@drawable/bg_rhs_status_inner"
|
||||||
|
android:padding="6dp"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:minHeight="180dp">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="4dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:text="Session Log"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/rhs_title" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rv_log_entries"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="ifContentScrolls" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</ScrollView>
|
||||||
|
|
||||||
<!-- Right: Controls -->
|
<!-- Right: Controls -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|||||||
Reference in New Issue
Block a user