From bc6dd50fc4d8ca18803732ba3a6f2c4f0ed5d2db Mon Sep 17 00:00:00 2001 From: Zhifei Ge Date: Thu, 11 Jun 2026 14:41:25 +0800 Subject: [PATCH] Add motor telemetry UI and disconnect diagnosis panel --- app/libs/unity-bridge.aar | Bin 21402 -> 22118 bytes .../controlpanel/data/BridgeRepository.kt | 24 ++ .../controlpanel/ui/components/MotorOps.kt | 255 +++++++++++++++--- .../components/MotorRealtimeTelemetryPanel.kt | 146 ++++++++++ .../ui/components/MotorTelemetryModels.kt | 83 ++++++ .../MotorTelemetryScatterChartView.kt | 161 +++++++++++ .../ui/detail/ComponentDetailFragment.kt | 66 ++++- .../res/layout/fragment_component_detail.xml | 177 +++++++----- 8 files changed, 811 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/com/kiwii/controlpanel/ui/components/MotorRealtimeTelemetryPanel.kt create mode 100644 app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryModels.kt create mode 100644 app/src/main/java/com/kiwii/controlpanel/ui/components/MotorTelemetryScatterChartView.kt diff --git a/app/libs/unity-bridge.aar b/app/libs/unity-bridge.aar index 8fb521bcbc8107d29e351757f89c7b6ad7c7ed27..3ac499fd99eeab18398e4699dcd9083a77cae35e 100644 GIT binary patch delta 21488 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 b%2<>BQb_`kRFmLQJClS{5C%L|00000n+s6T delta 20783 zcmV(zK<2;ZtO1&*0kC!ff9^4yr!!Ch0QXe@01E&B0Ap-nb8}^LE^1+Nl)D9R9ldux zY-VPNDUO*bW@ct)W@e6=nVFelcFcIqF*CCr<27FMm-cNt|KB%lXR6s99Z5&Kt37%i zNoOP_Sx6|be-C7EWDsEgPx!ZNQ%8FyrTcIVbonYJE8cA?4u(`kY*Z=MnBJS39W}=P`7S@)Grgp}z zuKr1~ii1K}VKZey=)!0PmCkjHR&txhaucE=wNhRb9b^lv%LDV2WlK9p6T@MG{UqkO zI3=~4n^T_0xxAhhe*%5mf?#^P%!trkw2x{Rm@07=_N!lK`N<5QHpTlljfQg*>E@9K z+ve&Y?zg?>m(DqTYPio%+vP>ZGaD_-xu;rN19lNt>MfKg&1{F-<}#50TNW^|q4#A@ z;!fg93JQ{;W?MNfA_HiQ*P*T{uaFize1A@~r|?+}#oov%e?uf2Nxu-LbFSnM$Dn1O z#Z*nV>QLuaeR&dIRrj{qLG+I?jGNm z|6cHoOKV2PcyI$pQPCt{4KdhD%;Rw| zTMP;b#5rFQ*;BS;v9pA2Sj*W4j`qg}b-`S*xbdT@geKr6Ki;s4R+rl3?x~4O^{(2f z{rX(4TehZ9RnFHn^zbh_sT)!2V1$6Fp{IC~b?1cRk{_kbx8WigZbMU8!foZa>RwFY zl*f;Te_>jJ&M;T;wZ_EQ@fsuuYr#{Hr%hr=!o-ZuJDA~f5I>}E?cAJK}f!q8@L1pz5!Jg_dtm+ zld}!*d7X4Cxn;9dgC?qr&fj_qEDo3#KPXXyDPv-WH(RqP8? zc&@|Z3AiPAneQ*tK%Je!Qb-|rKA+o+BGQmd___SVf&c?e;nUQo=2li$S>tAz9*izx ze@<%5pP$*1PCOB~4`1pVn!?kmoPn_bym#@KGDe;oWJ_A!w$xD?yybS^4jf9`|n^;6us(K(f+ zy(RUt>jk*sj_a3dsC02PCZD|^9Bf&ZvTgA)OM1*UL)zl@(D~lrmyQ{TWcT~PUs1w* zt+qp2DV{F@*NZ#7>yYSzCqOl+6gMn6g?j$7usOc-OY!PN!2O4BA__Yc=E~W)fB5E! zY3+B<`jNa+xV)Sp=<-~V70iw?^h*>e?H*<6*lq5j~Wx0U|<4#U|>xD7ufs* znlv4FFD!AK04DgX-)16CA|e`4>`lYYT znt2is5`(=_)J`3E9iREexiz8Am|qM0AW(F^QxoW)hP@Sba3ih-k~z@shJ?CiE0s^4 z7cu4+0p<4c%7DfW>%+}qB{~5%dU|wnu6lec@?m;qKtAh}$r*^}s+6Dzf5aJ)xN{DJ z)M?~ytpf^&i6e5cNtGgYn{K)FBZ7v+Ht7TMGW{wbt#!h@S8I!;tzT@5ExUlmx7Wim z?E@TZU5@24{Tk<-mP>+H@gbdOYtOk?5k7?j_jwrht68qh5wa_{gA*@fgbwa0^s{)I z%`%{m_humwp1^bRAd0{df2$XB>y1u8nwrYiFS?~~8Kh>dxBzmqo|y&7Sgwu zto-U)a@czn4$hB&FmaBlkCbta>5n*ZoU*<(fOhLQN$<^K;3dHhe_2A{JV+s~ed6GI zT)SAkt~DI&-~R6 zM%e@O4yf1z=0_BzCrHoomqGY(1?~$EZe@fL?#!41C^OJ>ez zsLxp5E|Z+-Ue_Rie}e7RoWjb6!J8nAK$ahv2yOZ?Q&uN6`4?`9)}DWq?R(RpmCW9dg85 zojQXEf<#R%Bek3O{f$o`qMG`0dnix!@-rvsU45_KXmO{ge?8*sTynd4fxH9K zm&GrC5ZYLQw1p0ju(}z4Mm^7xDp9fteS&>t7fBFUWg!PV*0%u9`pifd2 zwFXj^32m_N&fkT5ifT%z*@FRE(P2mF6RA~2~~w%7+1IP9$IU456@eErlwZh zw?(?O4i_iWz2v;jdGIR4em=+7YAq9^*rQb`rNq_3NuDDG$%VbUW%3)j#jN5PT4*J> zK_3>;S!JK!O4>~I56tza%tZ~G66gpN1@d%)yvw`4fAbR{1}x$(p(aQv^%OGB`UhEc zg>YdFbNjez>YC23;9H}Lzj~d72HOXTfw%Ue@L#y1Nd{>(v_I=~W`utA(f^739D48M zbcg`E{Z)A{lNA;wRgufaz+_a-?QjDN*3V2jNWNiZMFO`!3B1Q)&ZXSgAM(xq@;9+* zWXhTue_O;b+o;biX2h4dvdK_lsE%MxHY1jDB9566rc9oFacy!WO+xmH8gd^uWoiNh zCd5}}=V1Go*k=_b15PGs(!Da2l7jQZMM5{_W;A6>uqvi&LE9H;0W6gi*QIbC9zWJ+ zikU*I+Guw0J=Al`V=@-iY&*T9qlp)UpQoW+e<>Yb`??vcs9uWgkX%6QOwyBABiR~wM`ne$nA4`#Z+zoOYk-9DTl5h|Aw&Dim1Tp3b z1A*Jg*9|(`Do%`1JlVqfW0XMlhT-Ew`2-KLVw!_tM(9YDccUh ze-GBPJBCrppd5M=Zw8C0j8M_MqDUqq8G2Ua%#s}4r~#HyrCqBJZKUmAH6DuWb=cChyzD(v1{vT8-b}g61U) zY?&z;y~9^OO622{d!akF`!pQS9HMmLf5XG3{Z~w^Z^L*A%xi>PvWSz_r9kftt}7;7 zJnUOul|z)wQ->@#`CgQcJEzdIu$*p2xPR`&OMJMjs<+#_6<9)bhS5j~^z(YJgvKH& z_#&+k_EaTNUg!&_c?=#3xWC49#@?z+V6&D` z7-36Z0hDkxV!Q*Y*dy`%8we4YL9*uq$(hSI9vhSJP#yv|zjyE}S=0Dw%jnBSC>`>uDH>vac zf*1leMEUoNeJgP>$;9W5_dG%RZd8=+ab{J-0D4SF)5t-e7P>E3vixtvf1mOJA6I*U zXnJ>w0pmm0cd&9H(>nAp1lk)vz1Fg`MB<g5w$-M*jIeb+8uf2Y51yHEdC_i2~!8EPqZt21liqnRmDUWsU5pJ3-WnqiHS zB2cc_pImFzocY82$M#tnrwOaV7PN&1``R#Sq$2cw!6c9QhB z7{Rw?Er098V##nD))sbi7V}B!4a^w0NsLN!ni?k93SJAJPo1fOG=|-9s+y@U3zY5+ zkz-N9G6vn;T(B`sf67%d+`4xJ2H_wkVZ`5hGf9t&X+zHmuw!rQ&Uzp_M zQ%NmP%L`Sm0si!9jp#f0+^>_{zB$;|vRG`~mB8P?*26`zPaHD2wZRrXGwgD63lH|P z>HiZ>t~l=#X>D`MGp_ymK-Z}xcl$*A5fo^a{kKi=TO{VbhVZQ&&s+zwEZ-WdO=LJ7 zB*Ta4|3tq)f2u!_{i>Ng>Yw{`N;-s6iCEBU+Tgjn(cD_Yzi9^*|1P<^`DPi^%R3)@ z_&~ML^5N;f4F5HYOHz(Bm6Lf|sfp?=t`ILPY^!Iz)4Wd3CtJb0cicm*PA;OXMRf0~ zP2o3n)=e{Gd-N+!-k-WZ&q}`-^*NN2ntea5$&Lg$^|)JAWxMWuV0=cZ>w5*$x$<+%57jRDCX94c<Qa`s&uWu4%sbwyXV|T8+{0$1*FEKoP-*`ZMra;^ zuwR)Qf1SVRF+u@JvmYf@XCXgd>dG5{P?4+OB4|EaCzdKcaxb|qBG-Pv5C^mOjxzd+ z?Dd&)@GjB4bLRDwtcx<{nA(50=mi%l29G%Xj-C5X6&AA-hq zDKJ8j7)Uj?a%jEzW$(AfJGLOp{jf34)))c9XWU5@YvLWg%kI!pG zf9*xvt{NbLikx#q)DnZBxh*Qby-!RaP#huSVxMIDMIieteX03I%#9iC z3q|K!7R%0J)-%bn`5ne=0)dW!JEfX<8hbab4`9c#}R`Xl8A%PpF%L zF+50dw(^qw9)Wg{&F->D`DRSStF%RgFBye%SMUx+W+Pmf7>=<8nF#fVSX?0^?7P%` zEm%J#ZHf9RF9Ml1GI>H7wIso6(`mzfci5#6BtEMpFAzJmId49ne^e7- zF(IWm6=aG`7D8=5W=CJ^LSqueH7BExneq6|dV4O1hA@E_eq??*tdTih7%vIcbPg}+ zOZyUDD(vUT6k%+-6s$SJlx_3$*l5ciHu9YL=94a~C%aXMFHCD(3QbOOEKX*=lA_W` z6JAuQ{wbm~g~sqoJVNk;lVpT4f5u-^2T-V)=!9uJMY0MJ$`Df@l=_5`C+K99Xmgb) zt3|lsQ&WmQusEn2XoaiXMAj6sC$b82p&u-3guUhAC%#do(yCLRgt&k&8dky_xl>T5 zW8ITjoe5egzBq!Lv)ykYyth}K1?mca4y1pG zK9+teD7A^~d_Uc(FaIQheqW$}UwA6*!%fYb^<=ugR$2J05B?BkcxU^oX{W+q z#-(CMF=C(%iJfkNjzC*pTi=?imKz4k<> z!ARkXn!A5#i`|q5BjHl@Bw(YkJEUx1zt#M}^kq2Mg(y7cN0zfve`z{3adV2zymd{g zUu#-24~v>BSVfaywMZsOfzoxzEx41Pl&Mh91+)J!LL@wSs z57m00uGI#M)GUf*T{MQB1M2jK9HW=j_Ea)U4|`t*&bRPWHlydw<+!3*P*{<3N7)=A zwjqSLPOa2LRod4xf4Y~1GIvav=*ny^65&9dHHB(f@;Mz;CL)2kv$8O!8L^5CB&YpK zlgfDRYA}STUhqp>rM{6v5K8MsAaXzStWNO0QZB`T4N|{9xIP%}Qa0lY!B@GT89GLm z_mH~XDSsBmggU8FtVO@7U>-9|KG*>6ER_O^zPhEeY@o%|e|k-1;%o5bq190b)TDmI z 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 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"> + + + + + + + + - +