Compare commits
3 Commits
8a90d29b2a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 883c040c30 | |||
| 7040752c3f | |||
| a3fc108156 |
@@ -38,7 +38,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(files("libs/unity-bridge.aar"))
|
implementation(files("libs/unity-bridge-aar-debug-v1.3.0.aar"))
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("androidx.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.
@@ -16,6 +16,15 @@ class BridgeRepository {
|
|||||||
fun getRuntimeStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeStateJson()
|
fun getRuntimeStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeStateJson()
|
||||||
fun getHealthStateJson(): String = KiwiiRuntimeClientBridge.getHealthStateJson()
|
fun getHealthStateJson(): String = KiwiiRuntimeClientBridge.getHealthStateJson()
|
||||||
fun getDebugSnapshotJson(): String = KiwiiRuntimeClientBridge.getDebugSnapshotJson()
|
fun getDebugSnapshotJson(): String = KiwiiRuntimeClientBridge.getDebugSnapshotJson()
|
||||||
|
fun getDongleStateJson(): String {
|
||||||
|
return try {
|
||||||
|
val method = KiwiiRuntimeClientBridge::class.java.getMethod("getDongleStateJson")
|
||||||
|
method.invoke(null) as? String ?: getDebugSnapshotJson()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// Backward-compatible fallback for old unity-bridge.aar builds.
|
||||||
|
getDebugSnapshotJson()
|
||||||
|
}
|
||||||
|
}
|
||||||
fun getRuntimeLifecycleStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeLifecycleStateJson()
|
fun getRuntimeLifecycleStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeLifecycleStateJson()
|
||||||
fun requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason)
|
fun requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason)
|
||||||
|
|
||||||
@@ -28,14 +37,20 @@ 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> = KiwiiRuntimeClientBridge.getLeftHandleIMUQueue()
|
fun getLeftHandleIMUQueue(): Array<FloatArray> {
|
||||||
fun submitLeftStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitLeftStaticEffect(effectId)
|
val latest = KiwiiRuntimeClientBridge.getLeftHandleIMULatest()
|
||||||
fun submitLeftHeStream(streamId: Int, payload: String): String = KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, payload)
|
return if (latest.isEmpty()) emptyArray() else arrayOf(latest)
|
||||||
|
}
|
||||||
|
fun submitLeftHeStream(streamId: Int, payload: String): String =
|
||||||
|
KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, payload)
|
||||||
|
|
||||||
fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest()
|
fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest()
|
||||||
fun getRightHandleIMUQueue(): Array<FloatArray> = KiwiiRuntimeClientBridge.getRightHandleIMUQueue()
|
fun getRightHandleIMUQueue(): Array<FloatArray> {
|
||||||
fun submitRightStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitRightStaticEffect(effectId)
|
val latest = KiwiiRuntimeClientBridge.getRightHandleIMULatest()
|
||||||
fun submitRightHeStream(streamId: Int, payload: String): String = KiwiiRuntimeClientBridge.submitRightHeStream(streamId, payload)
|
return if (latest.isEmpty()) emptyArray() else arrayOf(latest)
|
||||||
|
}
|
||||||
|
fun submitRightHeStream(streamId: Int, payload: String): String =
|
||||||
|
KiwiiRuntimeClientBridge.submitRightHeStream(streamId, payload)
|
||||||
|
|
||||||
fun getHandleStateJson(): String = KiwiiRuntimeClientBridge.getHandleStateJson()
|
fun getHandleStateJson(): String = KiwiiRuntimeClientBridge.getHandleStateJson()
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ class ComponentRegistry(
|
|||||||
|
|
||||||
private var previousStates = emptyMap<ComponentType, ComponentState>()
|
private var previousStates = emptyMap<ComponentType, ComponentState>()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MOTOR_TELEMETRY_FRESH_TIMEOUT_MS = 5000L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motor is physically downstream of the SmartBase Dongle.
|
||||||
|
// Keep the latest Dongle card decision from the same detection loop so Motor cannot remain green
|
||||||
|
// after the Dongle transport is disconnected, while preserving v4 Dongle detection behavior.
|
||||||
|
@Volatile
|
||||||
|
private var latestDongleCardStateForMotorGate: ComponentState = ComponentState.GREY
|
||||||
|
|
||||||
fun startDetection() {
|
fun startDetection() {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
@@ -95,11 +105,12 @@ class ComponentRegistry(
|
|||||||
private fun dumpDebugOnce() {
|
private fun dumpDebugOnce() {
|
||||||
if (debugDumped || !repo.isBound()) return
|
if (debugDumped || !repo.isBound()) return
|
||||||
debugDumped = true
|
debugDumped = true
|
||||||
|
// 首页首次检测不要调用 getDebugSnapshotJson / camera / motor 等重 API。
|
||||||
|
// 这些 API 会触发 native snapshot 或 camera state 路径;RuntimeHost 刚启动时容易与
|
||||||
|
// native init/camera warm-start 形成 Binder 等待,导致 NOT_BOUND 或 service ANR。
|
||||||
val apis = mapOf(
|
val apis = mapOf(
|
||||||
"getCameraStateJson" to runCatching { repo.getCameraStateJson() }.getOrDefault("EXCEPTION"),
|
"isBound" to repo.isBound().toString(),
|
||||||
"getLatestMotorStateJson" to runCatching { repo.getLatestMotorStateJson() }.getOrDefault("EXCEPTION"),
|
"getDongleStateJson(first400)" to runCatching { repo.getDongleStateJson().take(400) }.getOrDefault("EXCEPTION")
|
||||||
"getDebugSnapshotJson(first200)" to runCatching { repo.getDebugSnapshotJson().take(200) }.getOrDefault("EXCEPTION"),
|
|
||||||
"getHandleStateJson" to runCatching { repo.getHandleStateJson() }.getOrDefault("EXCEPTION"),
|
|
||||||
)
|
)
|
||||||
for ((name, value) in apis) {
|
for ((name, value) in apis) {
|
||||||
logger.log(LogEntry(
|
logger.log(LogEntry(
|
||||||
@@ -153,12 +164,32 @@ class ComponentRegistry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun checkDongle(): ComponentState {
|
private fun checkDongle(): ComponentState {
|
||||||
|
val state = computeDongleState()
|
||||||
|
latestDongleCardStateForMotorGate = state
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeDongleState(): ComponentState {
|
||||||
return try {
|
return try {
|
||||||
// Dongle 是 USB 外设,状态在 getDebugSnapshotJson 的全量数据中
|
val json = repo.getDongleStateJson()
|
||||||
val json = repo.getDebugSnapshotJson()
|
|
||||||
if (isUnavailable(json)) return ComponentState.GREY
|
if (isUnavailable(json)) return ComponentState.GREY
|
||||||
// dongleState 字段存在说明 Dongle USB 已连接并被 RuntimeHost 识别
|
val obj = JSONObject(json)
|
||||||
if (json.contains("\"dongleState\"")) {
|
val transport = obj.optJSONObject("transport") ?: return ComponentState.GREY
|
||||||
|
|
||||||
|
val active = obj.optBoolean("active", false) || obj.optBoolean("available", false) || obj.optBoolean("connected", false)
|
||||||
|
val transportActive = transport.optBoolean("active", false) || transport.optBoolean("connected", false)
|
||||||
|
val usbBridgeRegistered = transport.optBoolean("usbBridgeRegistered", false)
|
||||||
|
val readLoopRunning = transport.optBoolean("readLoopRunning", false)
|
||||||
|
val usbPresent = transport.optBoolean("usbPresent", false)
|
||||||
|
val state = transport.optString("state", "")
|
||||||
|
|
||||||
|
// Dongle 首页卡片表达“USB transport 已经被 RuntimeHost 打开并注册”,
|
||||||
|
// 不要求 lastUsbInAgeMs < 3s。是否有上行数据滞后由 detail page 的 dataStale/lastUsbInAgeMs 显示。
|
||||||
|
if ((active || transportActive || state == "CONNECTED") &&
|
||||||
|
usbPresent &&
|
||||||
|
usbBridgeRegistered &&
|
||||||
|
readLoopRunning
|
||||||
|
) {
|
||||||
ComponentState.ACTIVE
|
ComponentState.ACTIVE
|
||||||
} else {
|
} else {
|
||||||
ComponentState.GREY
|
ComponentState.GREY
|
||||||
@@ -170,11 +201,23 @@ class ComponentRegistry(
|
|||||||
|
|
||||||
private fun checkMotor(): ComponentState {
|
private fun checkMotor(): ComponentState {
|
||||||
return try {
|
return try {
|
||||||
|
// Motor is physically downstream of the SmartBase Dongle.
|
||||||
|
// V7 requires fresh motor telemetry; stale cached telemetry after Dongle re-plug must not turn Motor green.
|
||||||
|
if (latestDongleCardStateForMotorGate != ComponentState.ACTIVE) return ComponentState.GREY
|
||||||
|
|
||||||
val json = repo.getLatestMotorStateJson()
|
val json = repo.getLatestMotorStateJson()
|
||||||
if (isUnavailable(json)) return ComponentState.GREY
|
if (isUnavailable(json)) return ComponentState.GREY
|
||||||
// motor-runtime-state.v1 has top-level "available" field
|
|
||||||
val obj = JSONObject(json)
|
val obj = JSONObject(json)
|
||||||
if (obj.optBoolean("available", false)) ComponentState.ACTIVE else ComponentState.GREY
|
if (obj.optString("contractVersion", "") != "kiwii.motor-runtime-state.v1") return ComponentState.GREY
|
||||||
|
if (obj.optBoolean("blockedByDongleTransport", false)) return ComponentState.GREY
|
||||||
|
if (obj.optBoolean("blockedByMotorTelemetry", false)) return ComponentState.GREY
|
||||||
|
if (!obj.optBoolean("available", false)) return ComponentState.GREY
|
||||||
|
|
||||||
|
val dataAgeMs = obj.optLong("dataAgeMs", Long.MAX_VALUE)
|
||||||
|
if (dataAgeMs < 0L || dataAgeMs > MOTOR_TELEMETRY_FRESH_TIMEOUT_MS) return ComponentState.GREY
|
||||||
|
|
||||||
|
ComponentState.ACTIVE
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
ComponentState.GREY
|
ComponentState.GREY
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class AarProxyStateSource(private val bridge: BridgeRepository) : RuntimeStateSo
|
|||||||
ComponentType.LEFT_HANDLE, ComponentType.RIGHT_HANDLE -> bridge.getHandleStateJson()
|
ComponentType.LEFT_HANDLE, ComponentType.RIGHT_HANDLE -> bridge.getHandleStateJson()
|
||||||
ComponentType.MOTOR -> bridge.getLatestMotorStateJson()
|
ComponentType.MOTOR -> bridge.getLatestMotorStateJson()
|
||||||
ComponentType.BALANCE_BOARD -> bridge.getDebugSnapshotJson()
|
ComponentType.BALANCE_BOARD -> bridge.getDebugSnapshotJson()
|
||||||
ComponentType.DONGLE -> bridge.getDebugSnapshotJson()
|
ComponentType.DONGLE -> bridge.getDongleStateJson()
|
||||||
ComponentType.TELEMETRY_SAFETY -> bridge.getTelemetryStateJson()
|
ComponentType.TELEMETRY_SAFETY -> bridge.getTelemetryStateJson()
|
||||||
ComponentType.SESSION_LOG -> "{}"
|
ComponentType.SESSION_LOG -> "{}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,44 @@ package com.kiwii.controlpanel.ui.components
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.TextView
|
import com.kiwii.controlpanel.model.ComponentType
|
||||||
import androidx.core.content.ContextCompat
|
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
||||||
import com.kiwii.controlpanel.R
|
|
||||||
|
|
||||||
class DongleOps(context: Context) : BaseOps(context) {
|
class DongleOps(
|
||||||
|
context: Context,
|
||||||
|
private val viewModel: ComponentDetailViewModel
|
||||||
|
) : BaseOps(context) {
|
||||||
|
|
||||||
override fun createView(): View {
|
override fun createView(): View {
|
||||||
val layout = verticalLayout()
|
val layout = verticalLayout()
|
||||||
layout.addView(TextView(context).apply {
|
|
||||||
text = "暂无 SDK API"
|
layout.addView(createSection("Dongle Transport"))
|
||||||
textSize = 12f
|
layout.addView(createButton("getDongleStateJson") {
|
||||||
setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle))
|
viewModel.executeOperation(ComponentType.DONGLE, "getDongleStateJson") {
|
||||||
setPadding(0, 32, 0, 32)
|
viewModel.bridgeRepo.getDongleStateJson()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
layout.addView(createButton("getDebugSnapshotJson") {
|
||||||
|
viewModel.executeOperation(ComponentType.DONGLE, "getDebugSnapshotJson") {
|
||||||
|
viewModel.bridgeRepo.getDebugSnapshotJson()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
layout.addView(createSection("Transport Probe"))
|
||||||
|
layout.addView(createButton("queryMotorTrainingMode") {
|
||||||
|
viewModel.executeOperation(ComponentType.DONGLE, "queryMotorTrainingMode", "axis=0") {
|
||||||
|
viewModel.bridgeRepo.queryMotorTrainingMode(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
layout.addView(createSection("Expected fields"))
|
||||||
|
layout.addView(createButton("Inspect transport + slots") {
|
||||||
|
viewModel.executeOperation(ComponentType.DONGLE, "inspectDongleDiagnostics") {
|
||||||
|
viewModel.bridgeRepo.getDongleStateJson()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return layout
|
return layout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,16 +30,6 @@ class LeftHandleOps(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
layout.addView(createSection("Haptics"))
|
|
||||||
val effectInput = createParameterInput("Effect ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
|
|
||||||
val staticEffectAction = createButton("submitLeftStaticEffect") {
|
|
||||||
val id = effectInput.text.toString().toIntOrNull() ?: 0
|
|
||||||
viewModel.executeOperation(ComponentType.LEFT_HANDLE, "submitLeftStaticEffect", "effectId=$id") {
|
|
||||||
viewModel.bridgeRepo.submitLeftStaticEffect(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
layout.addView(createParameterActionRow(staticEffectAction, effectInput))
|
|
||||||
|
|
||||||
val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
|
val 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") {
|
||||||
|
|||||||
@@ -30,16 +30,6 @@ class RightHandleOps(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
layout.addView(createSection("Haptics"))
|
|
||||||
val effectInput = createParameterInput("Effect ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
|
|
||||||
val staticEffectAction = createButton("submitRightStaticEffect") {
|
|
||||||
val id = effectInput.text.toString().toIntOrNull() ?: 0
|
|
||||||
viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "submitRightStaticEffect", "effectId=$id") {
|
|
||||||
viewModel.bridgeRepo.submitRightStaticEffect(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
layout.addView(createParameterActionRow(staticEffectAction, effectInput))
|
|
||||||
|
|
||||||
val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
|
val 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") {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class ComponentDetailFragment : Fragment() {
|
|||||||
ComponentType.LEFT_HANDLE -> LeftHandleOps(requireContext(), viewModel)
|
ComponentType.LEFT_HANDLE -> LeftHandleOps(requireContext(), viewModel)
|
||||||
ComponentType.RIGHT_HANDLE -> RightHandleOps(requireContext(), viewModel)
|
ComponentType.RIGHT_HANDLE -> RightHandleOps(requireContext(), viewModel)
|
||||||
ComponentType.BALANCE_BOARD -> BalanceBoardOps(requireContext())
|
ComponentType.BALANCE_BOARD -> BalanceBoardOps(requireContext())
|
||||||
ComponentType.DONGLE -> DongleOps(requireContext())
|
ComponentType.DONGLE -> DongleOps(requireContext(), viewModel)
|
||||||
ComponentType.MOTOR -> MotorOps(requireContext(), viewModel)
|
ComponentType.MOTOR -> MotorOps(requireContext(), viewModel)
|
||||||
ComponentType.TELEMETRY_SAFETY -> TelemetrySafetyOps(requireContext(), viewModel)
|
ComponentType.TELEMETRY_SAFETY -> TelemetrySafetyOps(requireContext(), viewModel)
|
||||||
ComponentType.SESSION_LOG -> SessionLogOps(requireContext())
|
ComponentType.SESSION_LOG -> SessionLogOps(requireContext())
|
||||||
@@ -143,7 +143,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 = RuntimeHostStatusParser.parse(snapshot.data, viewModel.bridgeRepo.isBound())
|
val items = parseStateItems(snapshot.data)
|
||||||
.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())
|
||||||
@@ -179,6 +179,16 @@ class ComponentDetailFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun observeLogs() {
|
private suspend fun observeLogs() {
|
||||||
viewModel.logEntries.collect { entries ->
|
viewModel.logEntries.collect { entries ->
|
||||||
val visibleEntries = entries.takeLast(80).ifEmpty {
|
val visibleEntries = entries.takeLast(80).ifEmpty {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.kiwii.controlpanel.ui.detail
|
||||||
|
|
||||||
|
import com.kiwii.bridge.MotorRuntimeState
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** 将 AAR 的 MotorRuntimeState 映射成详情页状态卡片。 */
|
||||||
|
object MotorRuntimeStateParser {
|
||||||
|
|
||||||
|
fun parse(json: String): List<StatItem> {
|
||||||
|
val state = try {
|
||||||
|
MotorRuntimeState.fromJson(json)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
StatItem("Available", state.available.toString()),
|
||||||
|
StatItem("Last Key", formatKey(state.lastKey), "motor uplink key"),
|
||||||
|
StatItem("Runtime", formatFloat(state.runtimeSec), "s"),
|
||||||
|
StatItem("Current", formatFloat(state.currentA), "A"),
|
||||||
|
StatItem("Torque", formatFloat(state.torqueNm), "N.m"),
|
||||||
|
StatItem("Velocity Rad", formatFloat(state.velocityRadps), "rad/s"),
|
||||||
|
StatItem("Velocity", formatFloat(state.velocityMps), "m/s"),
|
||||||
|
StatItem("Position", formatFloat(state.positionRad), "rad"),
|
||||||
|
StatItem("Extension", formatFloat(state.extensionM), "m"),
|
||||||
|
StatItem("Voltage", formatFloat(state.voltageV), "V"),
|
||||||
|
StatItem("Temperature", formatFloat(state.tempC), "C"),
|
||||||
|
StatItem("Sys", state.sys.toString()),
|
||||||
|
StatItem("Merr", state.merr.toString()),
|
||||||
|
StatItem("OBD", state.obd.toString()),
|
||||||
|
StatItem("Seq ID", state.seqId.toString()),
|
||||||
|
StatItem("T Data", state.tDataUnixMs.toString(), "Unix ms"),
|
||||||
|
StatItem("Fault", state.faultSummary.ifBlank { "none" }),
|
||||||
|
StatItem("Host Arrival", state.hostArrivalNs.toString(), "ns"),
|
||||||
|
StatItem("Last Upload", state.lastUploadNs.toString(), "ns"),
|
||||||
|
StatItem("Data Age", state.dataAgeMs.toString(), "ms"),
|
||||||
|
StatItem("Raw Line", state.rawLine.compact(), "raw debug")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatKey(key: Int): String = String.format(Locale.US, "0x%02X (%d)", key, key)
|
||||||
|
|
||||||
|
private fun formatFloat(value: Float): String = String.format(Locale.US, "%.4f", value)
|
||||||
|
|
||||||
|
private fun String.compact(): String {
|
||||||
|
return if (length > 48) "${take(45)}..." else this
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.kiwii.controlpanel
|
||||||
|
|
||||||
|
import com.kiwii.controlpanel.ui.detail.MotorRuntimeStateParser
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MotorRuntimeStateParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse maps new motor runtime fields`() {
|
||||||
|
val json = """
|
||||||
|
{
|
||||||
|
"contractVersion": "kiwii.motor-runtime-state.v1",
|
||||||
|
"available": true,
|
||||||
|
"lastKey": 175,
|
||||||
|
"runtimeSec": 12.5,
|
||||||
|
"currentA": 1.25,
|
||||||
|
"torqueNm": 2.5,
|
||||||
|
"velocityRadps": 3.5,
|
||||||
|
"velocityMps": 4.5,
|
||||||
|
"positionRad": 5.5,
|
||||||
|
"extensionM": 0.25,
|
||||||
|
"voltageV": 24.0,
|
||||||
|
"tempC": 36.5,
|
||||||
|
"sys": 7,
|
||||||
|
"merr": 8,
|
||||||
|
"obd": 9,
|
||||||
|
"seqId": 10,
|
||||||
|
"tDataUnixMs": 1710000000000,
|
||||||
|
"faultSummary": "ok",
|
||||||
|
"hostArrivalNs": 111,
|
||||||
|
"lastUploadNs": 222,
|
||||||
|
"dataAgeMs": 333,
|
||||||
|
"rawLine": "motor raw debug line",
|
||||||
|
"rawJson": "{\"available\":true}"
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val items = MotorRuntimeStateParser.parse(json)
|
||||||
|
|
||||||
|
assertEquals("true", items.first { it.label == "Available" }.value)
|
||||||
|
assertEquals("0xAF (175)", items.first { it.label == "Last Key" }.value)
|
||||||
|
assertEquals("12.5000", items.first { it.label == "Runtime" }.value)
|
||||||
|
assertEquals("ms", items.first { it.label == "Data Age" }.detail)
|
||||||
|
assertEquals("motor raw debug line", items.first { it.label == "Raw Line" }.value)
|
||||||
|
assertTrue(items.none { it.label == "Raw Json" })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user