1 Commits

Author SHA1 Message Date
pNexus 883c040c30 build(kiwii): 适配最新 AAR v1.3.0 接口 2026-06-12 17:47:19 +08:00
14 changed files with 212 additions and 887 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ android {
}
dependencies {
implementation(files("libs/unity-bridge.aar"))
implementation(files("libs/unity-bridge-aar-debug-v1.3.0.aar"))
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.fragment:fragment-ktx:1.6.2")
Binary file not shown.
Binary file not shown.
@@ -37,62 +37,28 @@ class BridgeRepository {
fun getLatestBodyPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestBodyPoseFrameJson()
fun getLeftHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLeftHandleIMULatest()
fun getLeftHandleIMUQueue(): Array<FloatArray> {
val queue = readHandleImuQueueCompat("getLeftHandleIMUQueue")
if (queue.isNotEmpty()) return queue
val latest = getLeftHandleIMULatest()
val latest = KiwiiRuntimeClientBridge.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, buildHeStreamPatternJson(streamId, payload))
KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, payload)
fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest()
fun getRightHandleIMUQueue(): Array<FloatArray> {
val queue = readHandleImuQueueCompat("getRightHandleIMUQueue")
if (queue.isNotEmpty()) return queue
val latest = getRightHandleIMULatest()
val latest = KiwiiRuntimeClientBridge.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, buildHeStreamPatternJson(streamId, payload))
KiwiiRuntimeClientBridge.submitRightHeStream(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()
@@ -100,48 +66,6 @@ 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()
@@ -30,16 +30,6 @@ class LeftHandleOps(
}
})
layout.addView(createSection("Haptics"))
val effectInput = createParameterInput("Effect ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val staticEffectAction = createButton("submitLeftStaticEffect") {
val id = effectInput.text.toString().toIntOrNull() ?: 0
viewModel.executeOperation(ComponentType.LEFT_HANDLE, "submitLeftStaticEffect", "effectId=$id") {
viewModel.bridgeRepo.submitLeftStaticEffect(id)
}
}
layout.addView(createParameterActionRow(staticEffectAction, effectInput))
val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT)
val heStreamAction = createButton("submitLeftHeStream") {
@@ -1,208 +1,63 @@
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 data class ParamDef(
val key: String,
val label: String,
val defaultValue: String,
val unit: String = "",
val definition: String = ""
private val trainingModes = arrayOf(
"NONE (0)", "FREE_WEIGHT (1)", "ECCENTRIC_OVERLOAD (2)",
"VISCOUS (3)", "ELASTIC (4)", "ISOKINETIC_SPOTTING (5)", "SPOTTER (6)"
)
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("Realtime Telemetry"))
layout.addView(MotorRealtimeTelemetryPanel(context, viewModel.bridgeRepo))
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("Force Control Parameters"))
layout.addView(createSection("Training Mode"))
val modeSpinner = Spinner(context).apply {
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, modes)
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, trainingModes)
}
layout.addView(modeSpinner)
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)
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(applyAction)
layout.addView(createParameterActionRow(trainingModeAction, modeSpinner, paramInput))
val queryCurrentAction = createButton("queryMotorTrainingMode current") {
viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "mode=-1") {
viewModel.bridgeRepo.queryMotorTrainingMode(-1)
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 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(createParameterActionRow(queryModeAction, axisInput))
layout.addView(createSection("State Queries"))
layout.addView(createButton("getLatestMotorStateJson") {
@@ -215,47 +70,7 @@ 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()
}
@@ -1,146 +0,0 @@
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()
}
@@ -1,83 +0,0 @@
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
}
}
@@ -1,161 +0,0 @@
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
}
@@ -30,16 +30,6 @@ class RightHandleOps(
}
})
layout.addView(createSection("Haptics"))
val effectInput = createParameterInput("Effect ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val staticEffectAction = createButton("submitRightStaticEffect") {
val id = effectInput.text.toString().toIntOrNull() ?: 0
viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "submitRightStaticEffect", "effectId=$id") {
viewModel.bridgeRepo.submitRightStaticEffect(id)
}
}
layout.addView(createParameterActionRow(staticEffectAction, effectInput))
val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT)
val heStreamAction = createButton("submitRightHeStream") {
@@ -8,12 +8,8 @@ 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
@@ -30,9 +26,7 @@ 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
@@ -47,14 +41,7 @@ class ComponentDetailFragment : Fragment() {
private lateinit var stateAdapter: StatAdapter
private lateinit var logAdapter: LogAdapter
private var latestStateText: String = "No state data"
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
private var latestResultText: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -76,8 +63,6 @@ class ComponentDetailFragment : Fragment() {
setupMenu()
setupStateAndLog()
ensureStateQueryResultsPanel()
updateOperationResultText(latestResultText)
loadOpsView()
observeViewModel()
}
@@ -121,21 +106,6 @@ 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)
@@ -173,7 +143,7 @@ class ComponentDetailFragment : Fragment() {
viewModel.stateSnapshot.collect { snapshot ->
if (snapshot != null) {
latestStateText = JsonFormatter.format(snapshot.data)
val items = RuntimeHostStatusParser.parse(snapshot.data, viewModel.bridgeRepo.isBound())
val items = parseStateItems(snapshot.data)
.ifEmpty { listOf(StatItem("State", "Updated")) }
stateAdapter.submitList(items)
val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
@@ -203,10 +173,19 @@ class ComponentDetailFragment : Fragment() {
latestResultText = "No Data"
}
null -> {
// Keep the initial guidance text visible until the first explicit State Query result arrives.
latestResultText = ""
}
}
updateOperationResultText(latestResultText)
}
}
private fun parseStateItems(json: String): List<StatItem> {
return if (componentType == ComponentType.MOTOR) {
MotorRuntimeStateParser.parse(json).ifEmpty {
RuntimeHostStatusParser.parse(json, viewModel.bridgeRepo.isBound())
}
} else {
RuntimeHostStatusParser.parse(json, viewModel.bridgeRepo.isBound())
}
}
@@ -226,34 +205,9 @@ 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)
@@ -276,12 +230,6 @@ 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"
}
}
@@ -0,0 +1,48 @@
package com.kiwii.controlpanel.ui.detail
import com.kiwii.bridge.MotorRuntimeState
import java.util.Locale
/** 将 AAR 的 MotorRuntimeState 映射成详情页状态卡片。 */
object MotorRuntimeStateParser {
fun parse(json: String): List<StatItem> {
val state = try {
MotorRuntimeState.fromJson(json)
} catch (_: Throwable) {
return emptyList()
}
return listOf(
StatItem("Available", state.available.toString()),
StatItem("Last Key", formatKey(state.lastKey), "motor uplink key"),
StatItem("Runtime", formatFloat(state.runtimeSec), "s"),
StatItem("Current", formatFloat(state.currentA), "A"),
StatItem("Torque", formatFloat(state.torqueNm), "N.m"),
StatItem("Velocity Rad", formatFloat(state.velocityRadps), "rad/s"),
StatItem("Velocity", formatFloat(state.velocityMps), "m/s"),
StatItem("Position", formatFloat(state.positionRad), "rad"),
StatItem("Extension", formatFloat(state.extensionM), "m"),
StatItem("Voltage", formatFloat(state.voltageV), "V"),
StatItem("Temperature", formatFloat(state.tempC), "C"),
StatItem("Sys", state.sys.toString()),
StatItem("Merr", state.merr.toString()),
StatItem("OBD", state.obd.toString()),
StatItem("Seq ID", state.seqId.toString()),
StatItem("T Data", state.tDataUnixMs.toString(), "Unix ms"),
StatItem("Fault", state.faultSummary.ifBlank { "none" }),
StatItem("Host Arrival", state.hostArrivalNs.toString(), "ns"),
StatItem("Last Upload", state.lastUploadNs.toString(), "ns"),
StatItem("Data Age", state.dataAgeMs.toString(), "ms"),
StatItem("Raw Line", state.rawLine.compact(), "raw debug")
)
}
private fun formatKey(key: Int): String = String.format(Locale.US, "0x%02X (%d)", key, key)
private fun formatFloat(value: Float): String = String.format(Locale.US, "%.4f", value)
private fun String.compact(): String {
return if (length > 48) "${take(45)}..." else this
}
}
@@ -7,139 +7,90 @@
android:background="@drawable/bg_rhs_card"
android:padding="8dp">
<!-- Left: scrollable State + State Query Results + Session Log -->
<ScrollView
<!-- Left: State + Log -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="45"
android:layout_marginEnd="6dp"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
android:orientation="vertical"
android:layout_marginEnd="6dp">
<!-- State Card -->
<LinearLayout
android:id="@+id/left_scroll_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="vertical"
android:background="@drawable/bg_rhs_status_inner"
android:padding="8dp">
<!-- State Card -->
<LinearLayout
<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: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:minHeight="360dp">
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">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="State Query Results / Disconnect Diagnosis"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:text="Session Log"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="@color/rhs_title"
android:layout_marginBottom="4dp" />
android:textColor="@color/rhs_title" />
</RelativeLayout>
<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
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_log_entries"
android:layout_width="match_parent"
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>
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never" />
</LinearLayout>
</ScrollView>
</LinearLayout>
<!-- Right: Controls -->
<LinearLayout
@@ -0,0 +1,49 @@
package com.kiwii.controlpanel
import com.kiwii.controlpanel.ui.detail.MotorRuntimeStateParser
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MotorRuntimeStateParserTest {
@Test
fun `parse maps new motor runtime fields`() {
val json = """
{
"contractVersion": "kiwii.motor-runtime-state.v1",
"available": true,
"lastKey": 175,
"runtimeSec": 12.5,
"currentA": 1.25,
"torqueNm": 2.5,
"velocityRadps": 3.5,
"velocityMps": 4.5,
"positionRad": 5.5,
"extensionM": 0.25,
"voltageV": 24.0,
"tempC": 36.5,
"sys": 7,
"merr": 8,
"obd": 9,
"seqId": 10,
"tDataUnixMs": 1710000000000,
"faultSummary": "ok",
"hostArrivalNs": 111,
"lastUploadNs": 222,
"dataAgeMs": 333,
"rawLine": "motor raw debug line",
"rawJson": "{\"available\":true}"
}
""".trimIndent()
val items = MotorRuntimeStateParser.parse(json)
assertEquals("true", items.first { it.label == "Available" }.value)
assertEquals("0xAF (175)", items.first { it.label == "Last Key" }.value)
assertEquals("12.5000", items.first { it.label == "Runtime" }.value)
assertEquals("ms", items.first { it.label == "Data Age" }.detail)
assertEquals("motor raw debug line", items.first { it.label == "Raw Line" }.value)
assertTrue(items.none { it.label == "Raw Json" })
}
}