diff --git a/app/libs/unity-bridge.aar b/app/libs/unity-bridge.aar index 8fb521b..3ac499f 100644 Binary files a/app/libs/unity-bridge.aar and b/app/libs/unity-bridge.aar differ diff --git a/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt b/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt index 558bc1a..3903fc2 100644 --- a/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt +++ b/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt @@ -58,9 +58,33 @@ class BridgeRepository { 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() diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorOps.kt index 6cf8865..e6e661e 100644 --- a/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorOps.kt +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorOps.kt @@ -1,63 +1,208 @@ package com.kiwii.controlpanel.ui.components import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.text.InputType +import android.view.Gravity import android.view.View +import android.widget.AdapterView import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.LinearLayout import android.widget.Spinner +import android.widget.TextView import com.kiwii.controlpanel.model.ComponentType import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel +import org.json.JSONObject class MotorOps( context: Context, private val viewModel: ComponentDetailViewModel ) : BaseOps(context) { - private val trainingModes = arrayOf( - "NONE (0)", "FREE_WEIGHT (1)", "ECCENTRIC_OVERLOAD (2)", - "VISCOUS (3)", "ELASTIC (4)", "ISOKINETIC_SPOTTING (5)", "SPOTTER (6)" + private data class ParamDef( + val key: String, + val label: String, + val defaultValue: String, + val unit: String = "", + val definition: String = "" ) + private data class ModeDef( + val mode: Int, + val name: String, + val description: String, + val params: List + ) { + override fun toString(): String = "$name ($mode)" + } + + private val spotterParams = listOf( + ParamDef("fm_sp_v", "Spotter velocity threshold", "0.1", "m/s", "Spotter 触发速度阈值;低于该速度并满足时间/位置条件时进入保护减重逻辑。"), + ParamDef("fm_sp_t", "Spotter trigger time", "5.0", "s", "Spotter 触发时间阈值;持续满足触发条件超过该时间后启动保护。"), + ParamDef("fm_sp_d", "Spotter decay duration", "5.0", "s", "Spotter 减重衰减时长;保护模式下降低负重的时间尺度。"), + ParamDef("fm_sp_home", "Spotter home", "0.0", "m", "Spotter 参考初始位置;用于判断用户是否离开安全范围。"), + ParamDef("fm_sp_rng", "Spotter load range", "-0.10", "m", "Spotter 负重判定位置阈值;通常为负值,表示相对 home 的保护触发位置范围。"), + ParamDef("fm_sp_rec", "Spotter recovery", "1.0", "s", "Spotter 恢复时长;保护结束后恢复到目标负重的时间尺度。") + ) + + private val pulleyParam = ParamDef("fm_rad", "Pulley radius", "0.04", "m", "滑轮/卷筒有效半径;输出扭矩 = 输出力 × fm_rad。该值直接影响力和绳长估算。") + + private val modes = listOf( + ModeDef(0, "None", "Output force is zero.", emptyList()), + ModeDef(1, "FreeWeight", "Free weight / inertia compensation.", listOf( + ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;FreeWeight 下相当于目标训练重量,不等于独立拉力传感器测得值。"), + ParamDef("fm_kin", "Inertia ratio", "0.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"), + ParamDef("fm_bfr", "Linear friction", "0.0", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"), + pulleyParam + )), + ModeDef(2, "EccentricOverload", "Eccentric overload with smooth concentric/eccentric switching.", listOf( + ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;EccentricOverload 下作为向心阶段基准目标负重。"), + ParamDef("fm_kin", "Inertia ratio", "1.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"), + ParamDef("fm_bfr", "Linear friction", "0.05", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"), + ParamDef("fm_kecc", "Eccentric multiplier", "1.5", "", "离心倍率;离心阶段目标力 = fm_kecc × 向心阶段目标力。"), + ParamDef("fm_vth", "Velocity threshold", "0.3", "m/s", "离心/向心平滑切换速度阈值;用于避免速度方向切换时力突变。"), + pulleyParam + ) + spotterParams), + ModeDef(3, "Viscous", "Viscous / fluid resistance.", listOf( + ParamDef("fm_cdrv", "Drive damping", "0.1", "N/(m/s)^2", "粘滞拉出方向平方阻尼系数;拉出速度越大阻尼增长越快。"), + ParamDef("fm_crec", "Recovery damping", "0.1", "N/(m/s)", "粘滞回收方向线性阻尼系数;回收阶段按速度线性给阻尼。"), + pulleyParam + )), + ModeDef(4, "Elastic", "Elastic spring mode.", listOf( + ParamDef("fm_k", "Spring stiffness", "0.0", "N/m", "弹性刚度;输出力随位置偏移按弹簧模型变化。"), + ParamDef("fm_x0", "Zero position", "0.0", "m", "弹性零点位置;rope length 与该位置的差决定弹性力。"), + pulleyParam + )), + ModeDef(5, "IsokineticSpotting", "Isokinetic velocity wall plus spotting.", listOf( + ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;作为等速保护和速度墙前的基础阻力。"), + ParamDef("fm_vmax", "Max velocity", "1.0", "m/s", "等速模式最大速度;超过该速度时由速度墙施加额外阻力。"), + ParamDef("fm_gwall", "Velocity wall gain", "0.0", "N/(m/s)", "等速速度墙增益;超出 fm_vmax 后按速度误差增加阻力。"), + pulleyParam + ) + spotterParams), + ModeDef(6, "Spotter", "Spotter protection mode.", listOf( + ParamDef("fm_mset", "Weight", "5.0", "kg", "用户设定重量/目标虚拟负重;Spotter 模式下作为保护逻辑的基础负重。"), + ParamDef("fm_kin", "Inertia ratio", "0.0", "", "惯性比例系数;虚拟质量 = fm_kin × fm_mset,用于惯性补偿。"), + ParamDef("fm_bfr", "Linear friction", "0.0", "N/(m/s)", "线性摩擦/阻尼系数;按绳速产生附加阻尼力。"), + pulleyParam + ) + spotterParams) + ) + + private val signedDecimalInputType = InputType.TYPE_CLASS_NUMBER or + InputType.TYPE_NUMBER_FLAG_DECIMAL or + InputType.TYPE_NUMBER_FLAG_SIGNED + override fun createView(): View { val layout = verticalLayout() - layout.addView(createSection("Weight")) - val weightInput = createParameterInput( - "Weight (kg)", - android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL - ) - val weightAction = createButton("setMotorWeightKg") { - val weight = weightInput.text.toString().toFloatOrNull() ?: 0f - viewModel.executeOperation(ComponentType.MOTOR, "setMotorWeightKg", "weight=$weight") { - viewModel.bridgeRepo.setMotorWeightKg(weight) - } - } - layout.addView(createParameterActionRow(weightAction, weightInput)) + layout.addView(createSection("Realtime Telemetry")) + layout.addView(MotorRealtimeTelemetryPanel(context, viewModel.bridgeRepo)) - layout.addView(createSection("Training Mode")) + layout.addView(createSection("Force Control Parameters")) val modeSpinner = Spinner(context).apply { - adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, trainingModes) + adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, modes) } - val paramInput = createParameterInput( - "Param (float)", - android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL - ) - val trainingModeAction = createButton("setMotorTrainingMode") { - val mode = modeSpinner.selectedItemPosition - val param = paramInput.text.toString().toFloatOrNull() ?: 0f - viewModel.executeOperation(ComponentType.MOTOR, "setMotorTrainingMode", "mode=$mode,param=$param") { - viewModel.bridgeRepo.setMotorTrainingMode(mode, param) - } - } - layout.addView(createParameterActionRow(trainingModeAction, modeSpinner, paramInput)) + layout.addView(modeSpinner) - val axisInput = createParameterInput("Axis (int)", android.text.InputType.TYPE_CLASS_NUMBER) - val queryModeAction = createButton("queryMotorTrainingMode") { - val axis = axisInput.text.toString().toIntOrNull() ?: 0 - viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "axis=$axis") { - viewModel.bridgeRepo.queryMotorTrainingMode(axis) + val description = TextView(context).apply { + textSize = 10f + setPadding(6.dp(), 4.dp(), 6.dp(), 4.dp()) + } + layout.addView(description) + + val paramContainer = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + layout.addView(paramContainer) + + val definitionText = TextView(context).apply { + textSize = 9.5f + setPadding(6.dp(), 4.dp(), 6.dp(), 8.dp()) + } + layout.addView(definitionText) + + val editTexts = LinkedHashMap() + 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("input:${param.key}") + editTexts[param.key] = editText + } + } + paramContainer.addView(createParameterRow(*rowViews.toTypedArray())) + } + definitionText.text = buildString { + append("Parameter definitions:\n") + append("fm_mode: 力控模式,当前选择 ${mode.name} (${mode.mode})。\n") + mode.params.forEach { param -> + append(param.key) + append(": ") + append(if (param.definition.isNotBlank()) param.definition else param.label) + if (param.unit.isNotBlank()) { + append(" [") + append(param.unit) + append("]") + } + append('\n') + } + append("\nNote: fm_mset 是目标虚拟负重;实时 Force kgf 是由电机电流/扭矩估算的张力等效重量,不是第三方拉力传感器读数。") + }.trim() + } + + modeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + rebuildParamForm(modes[position]) + } + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + rebuildParamForm(modes.first()) + + val applyAction = createButton("setMotorForceControlParamsJson") { + val mode = modes[modeSpinner.selectedItemPosition] + val payload = JSONObject().apply { + put("fm_mode", mode.mode) + for ((key, editText) in editTexts) { + val value = editText.text.toString().trim().toDoubleOrNull() + if (value != null) put(key, value) + } + }.toString() + viewModel.executeOperation(ComponentType.MOTOR, "setMotorForceControlParamsJson", payload) { + viewModel.bridgeRepo.setMotorForceControlParamsJson(payload) } } - layout.addView(createParameterActionRow(queryModeAction, axisInput)) + layout.addView(applyAction) + + val queryCurrentAction = createButton("queryMotorTrainingMode current") { + viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "mode=-1") { + viewModel.bridgeRepo.queryMotorTrainingMode(-1) + } + } + val querySelectedAction = createButton("queryMotorTrainingMode selected") { + val mode = modes[modeSpinner.selectedItemPosition] + viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "mode=${mode.mode}") { + viewModel.bridgeRepo.queryMotorTrainingMode(mode.mode) + } + } + layout.addView(queryCurrentAction) + layout.addView(querySelectedAction) layout.addView(createSection("State Queries")) layout.addView(createButton("getLatestMotorStateJson") { @@ -70,7 +215,47 @@ class MotorOps( viewModel.bridgeRepo.getMotorControlStateJson() } }) + layout.addView(createButton("getMotorDisconnectDiagnosisJson") { + viewModel.executeOperation(ComponentType.MOTOR, "getMotorDisconnectDiagnosisJson") { + viewModel.bridgeRepo.getMotorDisconnectDiagnosisJson() + } + }) + layout.addView(createButton("sendMotorHeartbeat") { + viewModel.executeOperation(ComponentType.MOTOR, "sendMotorHeartbeat") { + viewModel.bridgeRepo.sendMotorHeartbeat() + } + }) return layout } + + private fun createLabeledParameterInput(param: ParamDef): LinearLayout { + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(2.dp(), 0, 2.dp(), 0) + + addView(TextView(context).apply { + text = "${param.key} =" + textSize = 10.5f + typeface = Typeface.MONOSPACE + setTextColor(Color.rgb(38, 50, 56)) + gravity = Gravity.CENTER_VERTICAL + }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 0.95f)) + + addView(EditText(context).apply { + tag = "input:${param.key}" + inputType = signedDecimalInputType + setSingleLine(true) + setText(param.defaultValue) + hint = param.unit.ifBlank { param.label } + textSize = 11f + typeface = Typeface.MONOSPACE + setPadding(4.dp(), 0, 4.dp(), 0) + minHeight = 34.dp() + }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1.05f)) + } + } + + private fun Int.dp(): Int = (this * context.resources.displayMetrics.density).toInt() } diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorRealtimeTelemetryPanel.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorRealtimeTelemetryPanel.kt new file mode 100644 index 0000000..c1a39af --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorRealtimeTelemetryPanel.kt @@ -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() +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryModels.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryModels.kt new file mode 100644 index 0000000..ec62113 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryModels.kt @@ -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 + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryScatterChartView.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryScatterChartView.kt new file mode 100644 index 0000000..62f1e7d --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryScatterChartView.kt @@ -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() + 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): Pair { + 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 +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailFragment.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailFragment.kt index a144424..5665a2c 100644 --- a/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailFragment.kt +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailFragment.kt @@ -8,8 +8,12 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels @@ -26,7 +30,9 @@ import com.kiwii.controlpanel.model.ComponentType import com.kiwii.controlpanel.model.OperationResult import com.kiwii.controlpanel.ui.components.* import com.kiwii.controlpanel.util.JsonFormatter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -41,7 +47,14 @@ class ComponentDetailFragment : Fragment() { private lateinit var stateAdapter: StatAdapter private lateinit var logAdapter: LogAdapter private var latestStateText: String = "No state data" - private var latestResultText: String = "—" + private var latestResultText: String = """ + State Query Results will appear here. + For Motor, use getLatestMotorStateJson / getMotorDisconnectDiagnosisJson, or wait for auto disconnect diagnosis. + + Left column is scrollable; scroll down to see the full result and Session Log. + """.trimIndent() + private var operationResultTextView: TextView? = null + private var lastAutoDisconnectDiagnosisTimestamp: Long = 0L override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -63,6 +76,8 @@ class ComponentDetailFragment : Fragment() { setupMenu() setupStateAndLog() + ensureStateQueryResultsPanel() + updateOperationResultText(latestResultText) loadOpsView() observeViewModel() } @@ -106,6 +121,21 @@ class ComponentDetailFragment : Fragment() { binding.rvLogEntries.adapter = logAdapter } + private fun ensureStateQueryResultsPanel() { + // v17 uses a static XML panel inside the left ScrollView. The left column is now + // scrollable like the right Controls column, and the State Query Results area has + // a large reserved height so long JSON / disconnect diagnosis remains visible. + operationResultTextView = binding.tvOperationResult + } + + private fun updateOperationResultText(text: String) { + operationResultTextView?.text = text + } + + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density + 0.5f).toInt() + } + private fun loadOpsView() { val opsView = when (componentType) { ComponentType.RUNTIME_HOST_STATUS -> RuntimeHostStatusOps(requireContext(), viewModel) @@ -173,9 +203,10 @@ class ComponentDetailFragment : Fragment() { latestResultText = "No Data" } null -> { - latestResultText = "—" + // Keep the initial guidance text visible until the first explicit State Query result arrives. } } + updateOperationResultText(latestResultText) } } @@ -195,9 +226,34 @@ class ComponentDetailFragment : Fragment() { binding.rvLogEntries.scrollToPosition(visibleEntries.size - 1) } } + maybeAutoShowMotorDisconnectDiagnosis(visibleEntries) } } + private suspend fun maybeAutoShowMotorDisconnectDiagnosis(entries: List) { + if (componentType != ComponentType.MOTOR) return + val event = entries.lastOrNull { entry -> + entry.component == ComponentType.MOTOR && + entry.action == "availability_change" && + (entry.result ?: "").contains("ACTIVE -> GREY") + } ?: return + if (event.timestamp <= lastAutoDisconnectDiagnosisTimestamp) return + + lastAutoDisconnectDiagnosisTimestamp = event.timestamp + val diagnosis = withContext(Dispatchers.IO) { + viewModel.bridgeRepo.getMotorDisconnectDiagnosisJson() + } + latestResultText = JsonFormatter.format(diagnosis) + updateOperationResultText(latestResultText) + SessionLogger.log(LogEntry( + timestamp = System.currentTimeMillis(), + component = ComponentType.MOTOR, + action = "autoDisconnectDiagnosis", + params = "trigger=${event.result}", + result = diagnosis + )) + } + private fun formatResultData(data: Any?): String { return when (data) { is String -> JsonFormatter.format(data) @@ -220,6 +276,12 @@ class ComponentDetailFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() + operationResultTextView = null _binding = null } + + companion object { + private const val STATE_QUERY_PANEL_TAG = "state_query_results_panel_v15" + private const val STATE_QUERY_TEXT_TAG = "state_query_results_text_v15" + } } diff --git a/app/src/main/res/layout/fragment_component_detail.xml b/app/src/main/res/layout/fragment_component_detail.xml index 3e03e15..8f62553 100644 --- a/app/src/main/res/layout/fragment_component_detail.xml +++ b/app/src/main/res/layout/fragment_component_detail.xml @@ -7,90 +7,139 @@ android:background="@drawable/bg_rhs_card" android:padding="8dp"> - - + + android:layout_marginEnd="6dp" + android:fillViewport="true" + android:overScrollMode="ifContentScrolls"> - + android:orientation="vertical"> - - - - - - + + + + + + + + + + + + + + - - - - - - - - + android:minHeight="360dp"> - + android:textColor="@color/rhs_title" + android:layout_marginBottom="4dp" /> - + + + + + + + + android:layout_height="260dp" + android:orientation="vertical" + android:background="@drawable/bg_rhs_status_inner" + android:padding="6dp" + android:layout_marginTop="6dp" + android:minHeight="180dp"> + + + + + + + + - +