2 Commits

14 changed files with 889 additions and 214 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ android {
} }
dependencies { dependencies {
implementation(files("libs/unity-bridge-aar-debug-v1.3.0.aar")) implementation(files("libs/unity-bridge.aar"))
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.fragment:fragment-ktx:1.6.2") implementation("androidx.fragment:fragment-ktx:1.6.2")
Binary file not shown.
Binary file not shown.
@@ -37,28 +37,62 @@ class BridgeRepository {
fun getLatestBodyPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestBodyPoseFrameJson() fun getLatestBodyPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestBodyPoseFrameJson()
fun getLeftHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLeftHandleIMULatest() fun getLeftHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLeftHandleIMULatest()
fun getLeftHandleIMUQueue(): Array<FloatArray> { fun getLeftHandleIMUQueue(): Array<FloatArray> {
val latest = KiwiiRuntimeClientBridge.getLeftHandleIMULatest() val queue = readHandleImuQueueCompat("getLeftHandleIMUQueue")
if (queue.isNotEmpty()) return queue
val latest = getLeftHandleIMULatest()
return if (latest.isEmpty()) emptyArray() else arrayOf(latest) return if (latest.isEmpty()) emptyArray() else arrayOf(latest)
} }
fun submitLeftStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitLeftStaticEffect(effectId)
fun submitLeftHeStream(streamId: Int, payload: String): String = fun submitLeftHeStream(streamId: Int, payload: String): String =
KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, payload) KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, buildHeStreamPatternJson(streamId, payload))
fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest() fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest()
fun getRightHandleIMUQueue(): Array<FloatArray> { fun getRightHandleIMUQueue(): Array<FloatArray> {
val latest = KiwiiRuntimeClientBridge.getRightHandleIMULatest() val queue = readHandleImuQueueCompat("getRightHandleIMUQueue")
if (queue.isNotEmpty()) return queue
val latest = getRightHandleIMULatest()
return if (latest.isEmpty()) emptyArray() else arrayOf(latest) return if (latest.isEmpty()) emptyArray() else arrayOf(latest)
} }
fun submitRightStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitRightStaticEffect(effectId)
fun submitRightHeStream(streamId: Int, payload: String): String = fun submitRightHeStream(streamId: Int, payload: String): String =
KiwiiRuntimeClientBridge.submitRightHeStream(streamId, payload) KiwiiRuntimeClientBridge.submitRightHeStream(streamId, buildHeStreamPatternJson(streamId, payload))
fun getHandleStateJson(): String = KiwiiRuntimeClientBridge.getHandleStateJson() fun getHandleStateJson(): String = KiwiiRuntimeClientBridge.getHandleStateJson()
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()
@@ -66,6 +100,48 @@ class BridgeRepository {
fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command) fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command)
fun getDeviceCommandStateJson(): String = KiwiiRuntimeClientBridge.getDeviceCommandStateJson() 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) { suspend fun <T> callAsync(block: () -> T): OperationResult<T> = withContext(Dispatchers.IO) {
try { try {
val result = block() val result = block()
@@ -30,6 +30,16 @@ 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 streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT) val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT)
val heStreamAction = createButton("submitLeftHeStream") { val heStreamAction = createButton("submitLeftHeStream") {
@@ -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()
} }
@@ -0,0 +1,146 @@
package com.kiwii.controlpanel.ui.components
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import com.kiwii.controlpanel.R
import com.kiwii.controlpanel.data.BridgeRepository
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
internal class MotorRealtimeTelemetryPanel(
context: Context,
private val repo: BridgeRepository
) : LinearLayout(context) {
private val mainHandler = Handler(Looper.getMainLooper())
private val chartView = MotorTelemetryScatterChartView(context)
private val headline = TextView(context)
private val temperatureLine = TextView(context)
private val subline = TextView(context)
private var executor: ScheduledExecutorService? = null
init {
orientation = VERTICAL
setBackgroundResource(R.drawable.bg_rhs_log_area)
setPadding(8.dp(), 8.dp(), 8.dp(), 8.dp())
addView(TextView(context).apply {
text = "Realtime force / rope length / temperature"
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.rgb(38, 50, 56))
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(headline.apply {
text = "Force: — kgf Rope: — cm"
textSize = 13f
typeface = Typeface.MONOSPACE
setTextColor(Color.rgb(20, 32, 40))
setPadding(0, 6.dp(), 0, 0)
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(temperatureLine.apply {
text = "Temp: — °C"
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.rgb(20, 32, 40))
setPadding(0, 3.dp(), 0, 0)
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(subline.apply {
text = "Polling RuntimeHost getLatestMotorStateJson() at 5 Hz"
textSize = 10f
setTextColor(Color.rgb(96, 111, 123))
setPadding(0, 2.dp(), 0, 6.dp())
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(chartView, LayoutParams(LayoutParams.MATCH_PARENT, 320.dp()))
addView(TextView(context).apply {
text = "Display convention: force is shown as positive kgf resistance/tension. This is motor-side telemetry derived from current/torque and fm_rad, not a calibrated external load-cell reading. RuntimeHost keeps rawForceN for native-sign debugging. Rope length uses extensionM, or -positionRad × fm_rad fallback."
textSize = 9.5f
setTextColor(Color.rgb(96, 111, 123))
gravity = Gravity.START
setPadding(0, 6.dp(), 0, 0)
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
startPolling()
}
override fun onDetachedFromWindow() {
stopPolling()
super.onDetachedFromWindow()
}
private fun startPolling() {
if (executor != null) return
executor = Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "kiwii-motor-telemetry-panel").apply { isDaemon = true }
}.also { exec ->
exec.scheduleAtFixedRate({ pollOnce() }, 0L, 200L, TimeUnit.MILLISECONDS)
}
}
private fun stopPolling() {
executor?.shutdownNow()
executor = null
mainHandler.removeCallbacksAndMessages(null)
}
private fun pollOnce() {
if (!repo.isBound()) {
mainHandler.post { setUnavailable("RuntimeHost not bound") }
return
}
val json = try {
repo.getLatestMotorStateJson()
} catch (t: Throwable) {
mainHandler.post { setUnavailable(t.message ?: t.javaClass.simpleName) }
return
}
val sample = MotorTelemetryParser.parse(json)
mainHandler.post {
if (sample == null) {
setUnavailable("No fresh motor telemetry")
} else {
updateSample(sample)
}
}
}
private fun updateSample(sample: MotorTelemetrySample) {
headline.text = "Force: ${fmt(sample.forceKg)} kgf Rope: ${fmt(sample.ropeLengthM * 100.0)} cm"
temperatureLine.text = "Temp: ${fmt(sample.tempC)} °C"
subline.text = "Force=${fmt(sample.forceN)} N I=${fmt(sample.currentA)} A V=${fmt(sample.voltageV)} V age=${sample.dataAgeMs} ms"
chartView.addSample(sample)
}
private fun setUnavailable(reason: String) {
headline.text = "Force: — kgf Rope: — cm"
temperatureLine.text = "Temp: — °C"
subline.text = reason
}
private fun fmt(value: Double): String {
if (!value.isFinite()) return ""
val absValue = kotlin.math.abs(value)
return when {
absValue >= 100.0 -> String.format("%.0f", value)
absValue >= 10.0 -> String.format("%.1f", value)
else -> String.format("%.2f", value)
}
}
private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt()
}
@@ -0,0 +1,83 @@
package com.kiwii.controlpanel.ui.components
import org.json.JSONObject
import kotlin.math.abs
internal data class MotorTelemetrySample(
val wallTimeMs: Long,
val motorRuntimeSec: Double,
val forceN: Double,
val forceKg: Double,
val ropeLengthM: Double,
val currentA: Double,
val voltageV: Double,
val tempC: Double,
val dataAgeMs: Long,
val available: Boolean,
val rawJson: String
)
internal object MotorTelemetryParser {
private const val STANDARD_GRAVITY_MPS2 = 9.80665
private const val DEFAULT_PULLEY_RADIUS_M = 0.04
private const val DEFAULT_MOTOR_KT_NM_PER_A = 1.0
fun parse(json: String, nowMs: Long = System.currentTimeMillis()): MotorTelemetrySample? {
if (json.isBlank() || json.contains("NOT_BOUND")) return null
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
if (obj.optString("contractVersion", "") != "kiwii.motor-runtime-state.v1") return null
val available = obj.optBoolean("available", false)
val dataAgeMs = obj.optLong("dataAgeMs", Long.MAX_VALUE)
if (!available || dataAgeMs < 0L || dataAgeMs > 2_000L) return null
val pulleyRadiusM = obj.firstFinite(
DEFAULT_PULLEY_RADIUS_M,
"pulleyRadiusM", "fm_rad", "fmRad", "radiusM"
).takeIf { abs(it) > 1e-6 } ?: DEFAULT_PULLEY_RADIUS_M
val motorKt = obj.firstFinite(DEFAULT_MOTOR_KT_NM_PER_A, "motorKtNmPerA", "ktNmPerA")
val currentA = obj.firstFinite(0.0, "currentA", "cur", "iqA", "iq")
val torqueNm = obj.firstFinite(Double.NaN, "torqueNm", "estimatedTorqueNm")
val signedForceN = obj.firstFinite(Double.NaN, "forceN", "outputForceN", "estimatedForceN")
.takeIf { it.isFinite() }
?: run {
val rawForceN = if (torqueNm.isFinite()) torqueNm / pulleyRadiusM else currentA * motorKt / pulleyRadiusM
-rawForceN
}
val forceN = abs(signedForceN)
val forceKg = obj.firstFinite(Double.NaN, "forceKg", "forceKgEquivalent")
.takeIf { it.isFinite() }
?.let { abs(it) }
?: forceN / STANDARD_GRAVITY_MPS2
val positionRad = obj.firstFinite(Double.NaN, "positionRad", "pos")
val ropeLengthM = obj.firstFinite(Double.NaN, "ropeLengthM", "extensionM", "cableLengthM")
.takeIf { it.isFinite() }
?: if (positionRad.isFinite()) -positionRad * pulleyRadiusM else Double.NaN
if (!forceKg.isFinite() || !ropeLengthM.isFinite()) return null
return MotorTelemetrySample(
wallTimeMs = nowMs,
motorRuntimeSec = obj.firstFinite(Double.NaN, "runtimeSec", "t"),
forceN = forceN,
forceKg = forceKg,
ropeLengthM = ropeLengthM,
currentA = currentA,
voltageV = obj.firstFinite(Double.NaN, "voltageV", "volt"),
tempC = obj.firstFinite(Double.NaN, "tempC", "temp"),
dataAgeMs = dataAgeMs,
available = available,
rawJson = json
)
}
private fun JSONObject.firstFinite(fallback: Double, vararg keys: String): Double {
for (key in keys) {
if (!has(key) || isNull(key)) continue
val value = optDouble(key, Double.NaN)
if (value.isFinite()) return value
}
return fallback
}
}
@@ -0,0 +1,161 @@
package com.kiwii.controlpanel.ui.components
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import java.util.ArrayDeque
import kotlin.math.max
internal class MotorTelemetryScatterChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val samples = ArrayDeque<MotorTelemetrySample>()
private val forcePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(245, 124, 0)
style = Paint.Style.FILL
}
private val lengthPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(25, 118, 210)
style = Paint.Style.FILL
}
private val tempPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(198, 40, 40)
style = Paint.Style.FILL
}
private val axisPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(130, 145, 160)
strokeWidth = 1f.dp()
style = Paint.Style.STROKE
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(70, 130, 145, 160)
strokeWidth = 1f.dp()
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(38, 50, 56)
textSize = 10f.sp()
}
private val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(38, 50, 56)
textSize = 11f.sp()
isFakeBoldText = true
}
private val forceArea = RectF()
private val lengthArea = RectF()
private val tempArea = RectF()
private val visibleWindowMs = 30_000L
fun addSample(sample: MotorTelemetrySample) {
samples.addLast(sample)
val minTime = sample.wallTimeMs - visibleWindowMs
while (!samples.isEmpty() && samples.first.wallTimeMs < minTime) {
samples.removeFirst()
}
invalidate()
}
fun clear() {
samples.clear()
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val w = width.toFloat()
val h = height.toFloat()
if (w <= 0f || h <= 0f) return
val padL = 44f.dp()
val padR = 10f.dp()
val padT = 22f.dp()
val gap = 22f.dp()
val padB = 18f.dp()
val chartH = (h - padT - gap * 2f - padB) / 3f
forceArea.set(padL, padT, w - padR, padT + chartH)
lengthArea.set(padL, padT + chartH + gap, w - padR, padT + chartH + gap + chartH)
tempArea.set(padL, padT + chartH * 2f + gap * 2f, w - padR, padT + chartH * 2f + gap * 2f + chartH)
if (samples.isEmpty()) {
canvas.drawText("Waiting for fresh motor telemetry", padL, h / 2f, titlePaint)
return
}
val now = samples.last.wallTimeMs
val minT = now - visibleWindowMs
val forceMinMax = minMax(samples.map { it.forceKg })
val lengthMinMax = minMax(samples.map { it.ropeLengthM * 100.0 })
val tempMinMax = minMax(samples.map { it.tempC })
drawPanel(canvas, forceArea, "Force kgf", forceMinMax.first, forceMinMax.second)
drawPanel(canvas, lengthArea, "Rope length cm", lengthMinMax.first, lengthMinMax.second)
drawPanel(canvas, tempArea, "Temperature °C", tempMinMax.first, tempMinMax.second)
for (sample in samples) {
val x = xOf(sample.wallTimeMs, minT, now, forceArea)
canvas.drawCircle(x, yOf(sample.forceKg, forceMinMax.first, forceMinMax.second, forceArea), 2.1f.dp(), forcePaint)
canvas.drawCircle(x, yOf(sample.ropeLengthM * 100.0, lengthMinMax.first, lengthMinMax.second, lengthArea), 2.1f.dp(), lengthPaint)
canvas.drawCircle(x, yOf(sample.tempC, tempMinMax.first, tempMinMax.second, tempArea), 2.1f.dp(), tempPaint)
}
canvas.drawText("last 30 s", tempArea.right - 52f.dp(), h - 5f.dp(), textPaint)
}
private fun drawPanel(canvas: Canvas, area: RectF, label: String, minValue: Double, maxValue: Double) {
canvas.drawRect(area, axisPaint)
canvas.drawText(label, area.left, area.top - 6f.dp(), titlePaint)
val mid = area.top + area.height() / 2f
canvas.drawLine(area.left, mid, area.right, mid, gridPaint)
canvas.drawText(formatNumber(maxValue), 4f.dp(), area.top + 10f.dp(), textPaint)
canvas.drawText(formatNumber((minValue + maxValue) / 2.0), 4f.dp(), mid + 4f.dp(), textPaint)
canvas.drawText(formatNumber(minValue), 4f.dp(), area.bottom, textPaint)
}
private fun xOf(timeMs: Long, minT: Long, maxT: Long, area: RectF): Float {
val denom = max(1L, maxT - minT).toDouble()
val f = ((timeMs - minT).toDouble() / denom).coerceIn(0.0, 1.0)
return area.left + (area.width() * f).toFloat()
}
private fun yOf(value: Double, minValue: Double, maxValue: Double, area: RectF): Float {
val denom = max(1e-6, maxValue - minValue)
val f = ((value - minValue) / denom).coerceIn(0.0, 1.0)
return area.bottom - (area.height() * f).toFloat()
}
private fun minMax(values: List<Double>): Pair<Double, Double> {
val finite = values.filter { it.isFinite() }
if (finite.isEmpty()) return 0.0 to 1.0
var mn = finite.minOrNull() ?: 0.0
var mx = finite.maxOrNull() ?: 1.0
if (mx - mn < 1e-6) {
val base = max(1.0, kotlin.math.abs(mx))
mn -= base * 0.05
mx += base * 0.05
}
val span = mx - mn
mn -= span * 0.08
mx += span * 0.08
return mn to mx
}
private fun formatNumber(value: Double): String {
if (!value.isFinite()) return ""
val absValue = kotlin.math.abs(value)
return when {
absValue >= 100.0 -> value.toInt().toString()
absValue >= 10.0 -> String.format("%.1f", value)
else -> String.format("%.2f", value)
}
}
private fun Float.dp(): Float = this * resources.displayMetrics.density
private fun Float.sp(): Float = this * resources.displayMetrics.scaledDensity
}
@@ -30,6 +30,16 @@ 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 streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT) val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT)
val heStreamAction = createButton("submitRightHeStream") { val heStreamAction = createButton("submitRightHeStream") {
@@ -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)
@@ -143,7 +173,7 @@ class ComponentDetailFragment : Fragment() {
viewModel.stateSnapshot.collect { snapshot -> viewModel.stateSnapshot.collect { snapshot ->
if (snapshot != null) { if (snapshot != null) {
latestStateText = JsonFormatter.format(snapshot.data) latestStateText = JsonFormatter.format(snapshot.data)
val items = parseStateItems(snapshot.data) val items = RuntimeHostStatusParser.parse(snapshot.data, viewModel.bridgeRepo.isBound())
.ifEmpty { listOf(StatItem("State", "Updated")) } .ifEmpty { listOf(StatItem("State", "Updated")) }
stateAdapter.submitList(items) stateAdapter.submitList(items)
val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
@@ -173,19 +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)
}
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())
} }
} }
@@ -205,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)
@@ -230,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"
}
} }
@@ -1,48 +0,0 @@
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,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
@@ -1,49 +0,0 @@
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" })
}
}