Compare commits
7 Commits
b7131fa7ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 883c040c30 | |||
| 7040752c3f | |||
| a3fc108156 | |||
| 8a90d29b2a | |||
| 0ff8e88054 | |||
| 6ee80834ee | |||
| 5cfa0fc591 |
+18
@@ -1 +1,19 @@
|
||||
.*/
|
||||
|
||||
# Gradle
|
||||
build/
|
||||
.gradle/
|
||||
|
||||
# Kotlin
|
||||
.kotlin/
|
||||
|
||||
# IDE
|
||||
*.iml
|
||||
local.properties
|
||||
|
||||
# APK/AAR outputs
|
||||
*.apk
|
||||
|
||||
# AAR (except libs/ dependencies that need to be committed)
|
||||
app/build/**/*.aar
|
||||
!app/libs/
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Control Panel Design Language
|
||||
|
||||
## Overall Structure
|
||||
|
||||
The control panel uses a clear engineering-dashboard layout. Screens are divided into large functional regions, not decorative sections. A detail screen should usually split into:
|
||||
|
||||
- a left information column
|
||||
- a right controls column
|
||||
- stacked status/log panels when more than one information region is needed
|
||||
|
||||
Panels should align their titles with their primary content. If a title describes a card grid, align the title with the grid's left edge. If a title describes a log window, align it with the log window's left edge.
|
||||
|
||||
Do not use decorative icons beside section titles. Titles should read as labels for the content region, not as illustrated headers.
|
||||
|
||||
## Color System
|
||||
|
||||
Use a restrained white-and-gray base with small semantic accents.
|
||||
|
||||
- Page background: `#F4F6F8`
|
||||
- Main panel background: `#FFFFFF`
|
||||
- Normal card background: `#FFFFFF`
|
||||
- Main panel border: `#C9D1D9`
|
||||
- Inner card border: `#D5DDE5`
|
||||
- Primary text: `#17202A`
|
||||
- Secondary text: `#52616F`
|
||||
- Muted text: `#7B8794`
|
||||
|
||||
Semantic colors:
|
||||
|
||||
- Primary blue: `#2F80ED`
|
||||
- Primary blue fill: `#F3F8FF`
|
||||
- Soft blue control fill: `#E8F1FF`
|
||||
- Success green: `#31A66A`
|
||||
- Success green fill: `#D9F5E5`
|
||||
- Warning yellow: `#D99000`
|
||||
- Warning yellow fill: `#FFFBED`
|
||||
- Scrollbar track: `#E5E9EE`
|
||||
- Scrollbar thumb: `#A4AFBA`
|
||||
|
||||
Avoid large colored surfaces. Blue, green, and yellow are reserved for semantic accents: primary operations, connected/success states, warnings, and log categories.
|
||||
|
||||
## Radius
|
||||
|
||||
Except scrollbars, all rectangular UI surfaces use one radius:
|
||||
|
||||
- Standard radius: `10px`
|
||||
|
||||
This applies to main panels, status cards, operation cards, chips, log windows, and reserved areas.
|
||||
|
||||
Scrollbars are auxiliary controls and may keep smaller radii:
|
||||
|
||||
- Scrollbar track radius: `6px`
|
||||
- Scrollbar thumb radius: `4.5px`
|
||||
|
||||
## Typography
|
||||
|
||||
Use straightforward system-safe typography.
|
||||
|
||||
- Primary UI font: `Arial, sans-serif`
|
||||
- Log/monospace font: `Courier, monospace`
|
||||
|
||||
Text hierarchy:
|
||||
|
||||
- Main section title: `22px`, `700`, `#17202A`
|
||||
- Section description: `14px`, `#52616F`
|
||||
- Operation card title: `22px`, `700`, `#17202A`
|
||||
- Operation card description: `14px`, `#52616F`
|
||||
- Status card label: `14px`, `600`, `#52616F`
|
||||
- Status card value: `38px`, `700`, `#17202A`
|
||||
- Log line: `15px`, monospace
|
||||
|
||||
## RuntimeHost Detail Layout
|
||||
|
||||
The RuntimeHost detail view is a two-column detail page.
|
||||
|
||||
Left column:
|
||||
|
||||
- top panel: runtime status summary
|
||||
- bottom panel: session log
|
||||
|
||||
Right column:
|
||||
|
||||
- full-height controls panel
|
||||
|
||||
### Runtime Status Panel
|
||||
|
||||
The status panel contains:
|
||||
|
||||
- a title and one-line description
|
||||
- a bound-state chip aligned to the title row
|
||||
- three horizontal status cards
|
||||
|
||||
Status cards use white backgrounds, gray borders, and the standard 10px radius. Each status card has three levels:
|
||||
|
||||
- small label
|
||||
- large core value
|
||||
- small semantic detail
|
||||
|
||||
Use semantic detail colors sparingly:
|
||||
|
||||
- connected/ready detail: `#31A66A`
|
||||
- warning/detail requiring attention: `#D99000`
|
||||
- informational detail: `#2F80ED`
|
||||
|
||||
### Session Log Panel
|
||||
|
||||
The log panel contains:
|
||||
|
||||
- a title and one-line description
|
||||
- a compact `Start / Stop` control on the title row
|
||||
- a large log window
|
||||
- a slim scrollbar
|
||||
|
||||
The log window is white with a gray border. Log text is monospace.
|
||||
|
||||
Log color mapping:
|
||||
|
||||
- TX: `#2F80ED`
|
||||
- RX: `#31A66A`
|
||||
- SYS: `#7B8794`
|
||||
|
||||
### Controls Panel
|
||||
|
||||
The controls panel contains:
|
||||
|
||||
- a title and one-line description
|
||||
- a regular operation-card grid
|
||||
- a reserved area at the bottom for future actions
|
||||
|
||||
Operation cards should have consistent size, spacing, title alignment, and radius.
|
||||
|
||||
Operation card color mapping:
|
||||
|
||||
- primary operations: fill `#F3F8FF`, border `#2F80ED`
|
||||
- normal query operations: fill `#FFFFFF`, border `#D5DDE5`
|
||||
- attention operations: fill `#FFFBED`, border `#D99000`
|
||||
- reserved/future area: fill `#FFFFFF`, border `#C9D1D9`, text `#52616F`
|
||||
|
||||
## Visual Rules
|
||||
|
||||
- Keep large surfaces white.
|
||||
- Use gray borders to define structure.
|
||||
- Use color only for semantics.
|
||||
- Avoid gradients, decorative textures, and illustrated title icons.
|
||||
- Avoid mixed corner radii in the same screen.
|
||||
- Align section titles to the primary content edge.
|
||||
- Keep controls in a predictable grid.
|
||||
- Keep logs dense and scannable.
|
||||
@@ -0,0 +1,56 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.kiwii.controlpanel"
|
||||
compileSdk {
|
||||
version = release(35)
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.kiwii.controlpanel"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(files("libs/unity-bridge-aar-debug-v1.3.0.aar"))
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:2.7.6")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:2.7.6")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.KiwiiControlPanel">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kiwii.controlpanel
|
||||
|
||||
import android.app.Application
|
||||
import com.kiwii.controlpanel.logging.SessionLogger
|
||||
|
||||
class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
SessionLogger.init(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.kiwii.controlpanel
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.setupActionBarWithNavController
|
||||
import com.kiwii.bridge.KiwiiRuntimeClientBridge
|
||||
import com.kiwii.controlpanel.databinding.ActivityMainBinding
|
||||
import com.kiwii.controlpanel.logging.LogEntry
|
||||
import com.kiwii.controlpanel.logging.SessionLogger
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val navHostFragment = supportFragmentManager
|
||||
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
val navController = navHostFragment.navController
|
||||
setupActionBarWithNavController(navController)
|
||||
|
||||
autoBind()
|
||||
}
|
||||
|
||||
private fun autoBind() {
|
||||
val result = KiwiiRuntimeClientBridge.bind(this)
|
||||
SessionLogger.log(LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
component = null,
|
||||
action = "auto_bind",
|
||||
params = null,
|
||||
result = "bind=$result",
|
||||
isStateChange = true
|
||||
))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (KiwiiRuntimeClientBridge.isBound()) {
|
||||
KiwiiRuntimeClientBridge.unbind()
|
||||
SessionLogger.log(LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
component = null,
|
||||
action = "auto_unbind",
|
||||
params = null,
|
||||
result = "Activity destroyed",
|
||||
isStateChange = true
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
val navHostFragment = supportFragmentManager
|
||||
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
return navHostFragment.navController.navigateUp() || super.onSupportNavigateUp()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.kiwii.controlpanel.data
|
||||
|
||||
import android.app.Activity
|
||||
import com.kiwii.bridge.KiwiiRuntimeClientBridge
|
||||
import com.kiwii.controlpanel.model.OperationResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class BridgeRepository {
|
||||
|
||||
fun bind(activity: Activity): Boolean = KiwiiRuntimeClientBridge.bind(activity)
|
||||
fun unbind() = KiwiiRuntimeClientBridge.unbind()
|
||||
fun isBound(): Boolean = KiwiiRuntimeClientBridge.isBound()
|
||||
fun ping(): String = KiwiiRuntimeClientBridge.ping()
|
||||
|
||||
fun getRuntimeStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeStateJson()
|
||||
fun getHealthStateJson(): String = KiwiiRuntimeClientBridge.getHealthStateJson()
|
||||
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 requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason)
|
||||
|
||||
fun getCameraStateJson(): String = KiwiiRuntimeClientBridge.getCameraStateJson()
|
||||
fun getPerceptionStateJson(): String = KiwiiRuntimeClientBridge.getPerceptionStateJson()
|
||||
fun getLatestPose2D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose2D()
|
||||
fun getLatestPose3D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose3D()
|
||||
fun getLatestRuntimeStats(): FloatArray = KiwiiRuntimeClientBridge.getLatestRuntimeStats()
|
||||
fun getLatestPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestPoseFrameJson()
|
||||
fun getLatestBodyPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestBodyPoseFrameJson()
|
||||
|
||||
fun getLeftHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLeftHandleIMULatest()
|
||||
fun getLeftHandleIMUQueue(): Array<FloatArray> {
|
||||
val latest = KiwiiRuntimeClientBridge.getLeftHandleIMULatest()
|
||||
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 getRightHandleIMUQueue(): Array<FloatArray> {
|
||||
val latest = KiwiiRuntimeClientBridge.getRightHandleIMULatest()
|
||||
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 setMotorWeightKg(weight: Float): com.kiwii.bridge.MotorCommandResult = KiwiiRuntimeClientBridge.setMotorWeightKg(weight)
|
||||
fun setMotorTrainingMode(mode: Int, param: Float): String = KiwiiRuntimeClientBridge.setMotorTrainingMode(mode, param)
|
||||
fun queryMotorTrainingMode(axis: Int): String = KiwiiRuntimeClientBridge.queryMotorTrainingMode(axis)
|
||||
fun getLatestMotorStateJson(): String = KiwiiRuntimeClientBridge.getLatestMotorStateJson()
|
||||
fun getMotorControlStateJson(): String = KiwiiRuntimeClientBridge.getMotorControlStateJson()
|
||||
|
||||
fun getTelemetryStateJson(): String = KiwiiRuntimeClientBridge.getTelemetryStateJson()
|
||||
fun getSafetyStateJson(): String = KiwiiRuntimeClientBridge.getSafetyStateJson()
|
||||
fun dryRunSafetyGate(command: String): String = KiwiiRuntimeClientBridge.dryRunSafetyGate(command)
|
||||
fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command)
|
||||
fun getDeviceCommandStateJson(): String = KiwiiRuntimeClientBridge.getDeviceCommandStateJson()
|
||||
|
||||
suspend fun <T> callAsync(block: () -> T): OperationResult<T> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val result = block()
|
||||
if (result == null || (result is String && result.isBlank())) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
OperationResult.NoData as OperationResult<T>
|
||||
} else {
|
||||
OperationResult.Success(result)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
OperationResult.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.kiwii.controlpanel.data
|
||||
|
||||
import android.util.Log
|
||||
import com.kiwii.controlpanel.logging.LogEntry
|
||||
import com.kiwii.controlpanel.logging.SessionLogger
|
||||
import com.kiwii.controlpanel.model.ComponentCategory
|
||||
import com.kiwii.controlpanel.model.ComponentState
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
||||
class ComponentRegistry(
|
||||
private val repo: BridgeRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val logger: SessionLogger
|
||||
) {
|
||||
private val _availability = MutableStateFlow<Map<ComponentType, ComponentState>>(emptyMap())
|
||||
val availability: StateFlow<Map<ComponentType, ComponentState>> = _availability
|
||||
|
||||
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() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
val result = ComponentType.entries.associateWith { checkAvailability(it) }
|
||||
// 检测状态变化,记录日志
|
||||
logStateChanges(previousStates, result)
|
||||
previousStates = result
|
||||
_availability.value = result
|
||||
delay(1000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logStateChanges(
|
||||
old: Map<ComponentType, ComponentState>,
|
||||
new: Map<ComponentType, ComponentState>
|
||||
) {
|
||||
if (old.isEmpty()) return
|
||||
for ((type, newState) in new) {
|
||||
val oldState = old[type] ?: continue
|
||||
if (oldState != newState) {
|
||||
logger.log(LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
component = type,
|
||||
action = "availability_change",
|
||||
params = null,
|
||||
result = "${oldState.name} -> ${newState.name}",
|
||||
isStateChange = true
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAvailability(type: ComponentType): ComponentState {
|
||||
dumpDebugOnce()
|
||||
return when (type.category) {
|
||||
ComponentCategory.ALWAYS_READY -> ComponentState.ACTIVE
|
||||
ComponentCategory.RUNTIME_HOST -> checkRuntimeHostComponent(type)
|
||||
ComponentCategory.PERIPHERAL -> checkPeripheral(type)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkRuntimeHostComponent(type: ComponentType): ComponentState {
|
||||
// RuntimeHostStatus 始终可交互(Bind 按钮需在未绑定时可用)
|
||||
if (type == ComponentType.RUNTIME_HOST_STATUS) return ComponentState.ACTIVE
|
||||
if (!repo.isBound()) return ComponentState.GREY
|
||||
return when (type) {
|
||||
ComponentType.CAMERA -> checkCamera()
|
||||
ComponentType.TELEMETRY_SAFETY -> ComponentState.ACTIVE
|
||||
else -> ComponentState.ACTIVE
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPeripheral(type: ComponentType): ComponentState {
|
||||
if (!repo.isBound()) return ComponentState.GREY
|
||||
return when (type) {
|
||||
ComponentType.LEFT_HANDLE -> checkHandle("left")
|
||||
ComponentType.RIGHT_HANDLE -> checkHandle("right")
|
||||
ComponentType.BALANCE_BOARD -> checkBalanceBoard()
|
||||
ComponentType.DONGLE -> checkDongle()
|
||||
ComponentType.MOTOR -> checkMotor()
|
||||
else -> ComponentState.GREY
|
||||
}
|
||||
}
|
||||
|
||||
private var debugDumped = false
|
||||
|
||||
private fun dumpDebugOnce() {
|
||||
if (debugDumped || !repo.isBound()) return
|
||||
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(
|
||||
"isBound" to repo.isBound().toString(),
|
||||
"getDongleStateJson(first400)" to runCatching { repo.getDongleStateJson().take(400) }.getOrDefault("EXCEPTION")
|
||||
)
|
||||
for ((name, value) in apis) {
|
||||
logger.log(LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
component = null,
|
||||
action = "DEBUG_DUMP",
|
||||
params = name,
|
||||
result = value,
|
||||
isStateChange = false
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkCamera(): ComponentState {
|
||||
return try {
|
||||
val json = repo.getCameraStateJson()
|
||||
if (isUnavailable(json)) ComponentState.GREY else ComponentState.ACTIVE
|
||||
} catch (_: Exception) {
|
||||
ComponentState.GREY
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkHandle(side: String): ComponentState {
|
||||
return try {
|
||||
val json = repo.getHandleStateJson()
|
||||
val obj = JSONObject(json)
|
||||
val sideObj = obj.optJSONObject(side) ?: return ComponentState.GREY
|
||||
// handle-state.v1: check available AND connected
|
||||
val available = sideObj.optBoolean("available", false)
|
||||
val connected = sideObj.optBoolean("connected", false)
|
||||
if (available || connected) ComponentState.ACTIVE else ComponentState.GREY
|
||||
} catch (_: Exception) {
|
||||
ComponentState.GREY
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBalanceBoard(): ComponentState {
|
||||
// BalanceBoard 没有专用 API,暂时用 debugSnapshot 判断
|
||||
// TODO: 确认 RuntimeHost debug snapshot 中 balance board 状态字段
|
||||
return try {
|
||||
val json = repo.getDebugSnapshotJson()
|
||||
if (isUnavailable(json)) return ComponentState.GREY
|
||||
val obj = JSONObject(json)
|
||||
val bb = obj.optJSONObject("balanceBoard") ?: obj.optJSONObject("balance_board")
|
||||
if (bb == null) ComponentState.GREY
|
||||
else if (bb.optBoolean("available", false) || bb.optBoolean("connected", false)) ComponentState.ACTIVE
|
||||
else ComponentState.GREY
|
||||
} catch (_: Exception) {
|
||||
ComponentState.GREY
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDongle(): ComponentState {
|
||||
val state = computeDongleState()
|
||||
latestDongleCardStateForMotorGate = state
|
||||
return state
|
||||
}
|
||||
|
||||
private fun computeDongleState(): ComponentState {
|
||||
return try {
|
||||
val json = repo.getDongleStateJson()
|
||||
if (isUnavailable(json)) return ComponentState.GREY
|
||||
val obj = JSONObject(json)
|
||||
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
|
||||
} else {
|
||||
ComponentState.GREY
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
ComponentState.GREY
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkMotor(): ComponentState {
|
||||
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()
|
||||
if (isUnavailable(json)) return ComponentState.GREY
|
||||
|
||||
val obj = JSONObject(json)
|
||||
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) {
|
||||
ComponentState.GREY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用不可用判断:含 error 字段、空 JSON、或 NOT_BOUND
|
||||
*/
|
||||
private fun isUnavailable(json: String): Boolean {
|
||||
if (json.isBlank() || json == "{}") return true
|
||||
if (json.contains("\"error\"")) return true
|
||||
if (json.contains("NOT_BOUND")) return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.kiwii.controlpanel.data
|
||||
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
|
||||
class RuntimeStateRepository(private var source: RuntimeStateSource) {
|
||||
|
||||
fun getComponentState(type: ComponentType): String = source.getState(type)
|
||||
|
||||
fun isUsingDebugChannel(): Boolean = source.isDebugChannelAvailable()
|
||||
|
||||
fun switchSource(newSource: RuntimeStateSource) {
|
||||
source = newSource
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.kiwii.controlpanel.data
|
||||
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
|
||||
interface RuntimeStateSource {
|
||||
fun getState(component: ComponentType): String
|
||||
fun isDebugChannelAvailable(): Boolean
|
||||
}
|
||||
|
||||
class AarProxyStateSource(private val bridge: BridgeRepository) : RuntimeStateSource {
|
||||
|
||||
override fun getState(component: ComponentType): String = when (component) {
|
||||
ComponentType.RUNTIME_HOST_STATUS -> bridge.getRuntimeStateJson()
|
||||
ComponentType.CAMERA -> bridge.getCameraStateJson()
|
||||
ComponentType.LEFT_HANDLE, ComponentType.RIGHT_HANDLE -> bridge.getHandleStateJson()
|
||||
ComponentType.MOTOR -> bridge.getLatestMotorStateJson()
|
||||
ComponentType.BALANCE_BOARD -> bridge.getDebugSnapshotJson()
|
||||
ComponentType.DONGLE -> bridge.getDongleStateJson()
|
||||
ComponentType.TELEMETRY_SAFETY -> bridge.getTelemetryStateJson()
|
||||
ComponentType.SESSION_LOG -> "{}"
|
||||
}
|
||||
|
||||
override fun isDebugChannelAvailable(): Boolean = false
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.kiwii.controlpanel.data
|
||||
|
||||
import com.kiwii.controlpanel.logging.LogEntry
|
||||
import com.kiwii.controlpanel.logging.SessionLogger
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.model.StateSnapshot
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class StatePoller(
|
||||
private val runtimeStateRepo: RuntimeStateRepository,
|
||||
private val bridgeRepo: BridgeRepository,
|
||||
private val logger: SessionLogger
|
||||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val _states = MutableStateFlow<Map<ComponentType, StateSnapshot>>(emptyMap())
|
||||
val states: StateFlow<Map<ComponentType, StateSnapshot>> = _states
|
||||
|
||||
private val pollIntervalMs = 1000L
|
||||
private var wasBound = false
|
||||
|
||||
fun startPolling() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val isBound = bridgeRepo.isBound()
|
||||
if (isBound) {
|
||||
if (!wasBound) {
|
||||
logger.log(LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
component = null,
|
||||
action = "connection",
|
||||
params = null,
|
||||
result = "BOUND",
|
||||
isStateChange = true
|
||||
))
|
||||
}
|
||||
val snapshot = pollAllStates()
|
||||
_states.value = snapshot
|
||||
} else {
|
||||
if (wasBound) {
|
||||
_states.value = emptyMap()
|
||||
logger.log(LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
component = null,
|
||||
action = "connection",
|
||||
params = null,
|
||||
result = "DISCONNECTED - states cleared",
|
||||
isStateChange = true
|
||||
))
|
||||
}
|
||||
}
|
||||
wasBound = isBound
|
||||
delay(pollIntervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pollAllStates(): Map<ComponentType, StateSnapshot> {
|
||||
val now = System.currentTimeMillis()
|
||||
val polledTypes = listOf(
|
||||
ComponentType.RUNTIME_HOST_STATUS,
|
||||
ComponentType.CAMERA,
|
||||
ComponentType.LEFT_HANDLE,
|
||||
ComponentType.RIGHT_HANDLE,
|
||||
ComponentType.BALANCE_BOARD,
|
||||
ComponentType.DONGLE,
|
||||
ComponentType.MOTOR,
|
||||
ComponentType.TELEMETRY_SAFETY
|
||||
)
|
||||
return polledTypes.mapNotNull { type ->
|
||||
try {
|
||||
type to StateSnapshot(
|
||||
data = runtimeStateRepo.getComponentState(type),
|
||||
updatedAt = now,
|
||||
isDebugChannel = runtimeStateRepo.isUsingDebugChannel()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// 异常必记
|
||||
logger.log(LogEntry(
|
||||
timestamp = now,
|
||||
component = type,
|
||||
action = "state_poll_error",
|
||||
params = null,
|
||||
result = "${e.javaClass.simpleName}: ${e.message}",
|
||||
isStateChange = false
|
||||
))
|
||||
type to StateSnapshot(
|
||||
data = "{\"error\":\"${e.message}\"}",
|
||||
updatedAt = now,
|
||||
isDebugChannel = runtimeStateRepo.isUsingDebugChannel(),
|
||||
hasError = true
|
||||
)
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
fun stopPolling() {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.kiwii.controlpanel.logging
|
||||
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
|
||||
data class LogEntry(
|
||||
val timestamp: Long,
|
||||
val component: ComponentType?,
|
||||
val action: String,
|
||||
val params: String?,
|
||||
val result: String?,
|
||||
val isStateChange: Boolean = false
|
||||
) {
|
||||
fun toLogLine(): String {
|
||||
val comp = component?.displayName ?: "GLOBAL"
|
||||
val p = params ?: ""
|
||||
val r = result ?: ""
|
||||
return "$timestamp|$comp|$action|$p|$r|$isStateChange"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.kiwii.controlpanel.logging
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
|
||||
object SessionLogger {
|
||||
|
||||
private val entries = mutableListOf<LogEntry>()
|
||||
private var fileWriter: BufferedWriter? = null
|
||||
private var logFile: File? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val file = File(context.cacheDir, "session_$timestamp.log")
|
||||
logFile = file
|
||||
fileWriter = file.bufferedWriter()
|
||||
}
|
||||
|
||||
fun log(entry: LogEntry) {
|
||||
synchronized(this) {
|
||||
entries.add(entry)
|
||||
fileWriter?.appendLine(entry.toLogLine())
|
||||
fileWriter?.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fun getEntries(): List<LogEntry> {
|
||||
synchronized(this) {
|
||||
return entries.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getEntriesForComponent(type: ComponentType): List<LogEntry> {
|
||||
synchronized(this) {
|
||||
return entries.filter { it.component == type }
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(this) {
|
||||
entries.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun export(context: Context): Uri? {
|
||||
val file = logFile ?: return null
|
||||
val exportDir = File(context.getExternalFilesDir(null), "logs")
|
||||
exportDir.mkdirs()
|
||||
val exportFile = File(exportDir, file.name)
|
||||
file.copyTo(exportFile, overwrite = true)
|
||||
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", exportFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.kiwii.controlpanel.model
|
||||
|
||||
enum class ComponentState {
|
||||
ACTIVE,
|
||||
GREY
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.kiwii.controlpanel.model
|
||||
|
||||
enum class ComponentCategory {
|
||||
ALWAYS_READY,
|
||||
RUNTIME_HOST,
|
||||
PERIPHERAL
|
||||
}
|
||||
|
||||
enum class ComponentType(
|
||||
val displayName: String,
|
||||
val category: ComponentCategory
|
||||
) {
|
||||
RUNTIME_HOST_STATUS("RuntimeHost Status", ComponentCategory.RUNTIME_HOST),
|
||||
CAMERA("Camera", ComponentCategory.RUNTIME_HOST),
|
||||
LEFT_HANDLE("Left Handle", ComponentCategory.PERIPHERAL),
|
||||
RIGHT_HANDLE("Right Handle", ComponentCategory.PERIPHERAL),
|
||||
BALANCE_BOARD("Balance Board", ComponentCategory.PERIPHERAL),
|
||||
DONGLE("Dongle", ComponentCategory.PERIPHERAL),
|
||||
MOTOR("Motor", ComponentCategory.PERIPHERAL),
|
||||
TELEMETRY_SAFETY("Telemetry & Safety", ComponentCategory.RUNTIME_HOST),
|
||||
SESSION_LOG("Session Log", ComponentCategory.ALWAYS_READY)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.kiwii.controlpanel.model
|
||||
|
||||
sealed class OperationResult<out T> {
|
||||
data class Success<T>(val data: T) : OperationResult<T>()
|
||||
data class Error(val exception: Exception) : OperationResult<Nothing>()
|
||||
data class NotBound(val defaultValue: String = "{\"error\":\"NOT_BOUND\"}") : OperationResult<Nothing>()
|
||||
data object NoData : OperationResult<Nothing>()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.kiwii.controlpanel.model
|
||||
|
||||
data class StateSnapshot(
|
||||
val data: String,
|
||||
val updatedAt: Long,
|
||||
val isDebugChannel: Boolean,
|
||||
val hasError: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.kiwii.controlpanel.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
class FlowLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : ViewGroup(context, attrs) {
|
||||
|
||||
class LayoutParams : MarginLayoutParams {
|
||||
constructor(width: Int, height: Int) : super(width, height)
|
||||
constructor(source: MarginLayoutParams) : super(source)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
}
|
||||
|
||||
override fun generateDefaultLayoutParams() = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
override fun generateLayoutParams(attrs: AttributeSet?) = LayoutParams(context, attrs)
|
||||
override fun generateLayoutParams(p: ViewGroup.LayoutParams?) = LayoutParams(MarginLayoutParams(p))
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val maxWidth = MeasureSpec.getSize(widthMeasureSpec)
|
||||
var x = paddingLeft
|
||||
var y = paddingTop
|
||||
var rowHeight = 0
|
||||
|
||||
for (i in 0 until childCount) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == View.GONE) continue
|
||||
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0)
|
||||
val lp = child.layoutParams as LayoutParams
|
||||
val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin
|
||||
val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin
|
||||
|
||||
if (x + childWidth > maxWidth - paddingRight) {
|
||||
x = paddingLeft
|
||||
y += rowHeight
|
||||
rowHeight = 0
|
||||
}
|
||||
x += childWidth
|
||||
rowHeight = maxOf(rowHeight, childHeight)
|
||||
}
|
||||
|
||||
setMeasuredDimension(maxWidth, y + rowHeight + paddingBottom)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
val maxWidth = r - l
|
||||
var x = paddingLeft
|
||||
var y = paddingTop
|
||||
var rowHeight = 0
|
||||
|
||||
for (i in 0 until childCount) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == View.GONE) continue
|
||||
val lp = child.layoutParams as LayoutParams
|
||||
val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin
|
||||
val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin
|
||||
|
||||
if (x + childWidth > maxWidth - paddingRight) {
|
||||
x = paddingLeft
|
||||
y += rowHeight
|
||||
rowHeight = 0
|
||||
}
|
||||
|
||||
child.layout(
|
||||
x + lp.leftMargin,
|
||||
y + lp.topMargin,
|
||||
x + lp.leftMargin + child.measuredWidth,
|
||||
y + lp.topMargin + child.measuredHeight
|
||||
)
|
||||
x += child.measuredWidth + lp.leftMargin + lp.rightMargin
|
||||
rowHeight = maxOf(rowHeight, childHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.kiwii.controlpanel.ui.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.kiwii.controlpanel.R
|
||||
import com.kiwii.controlpanel.databinding.ItemComponentCardBinding
|
||||
import com.kiwii.controlpanel.model.ComponentCategory
|
||||
import com.kiwii.controlpanel.model.ComponentState
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
|
||||
class ComponentCardAdapter(
|
||||
private val onClick: (ComponentType) -> Unit
|
||||
) : ListAdapter<Pair<ComponentType, ComponentState>, ComponentCardAdapter.ViewHolder>(DIFF) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemComponentCardBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val (type, state) = getItem(position)
|
||||
holder.bind(type, state)
|
||||
}
|
||||
|
||||
inner class ViewHolder(private val binding: ItemComponentCardBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(type: ComponentType, state: ComponentState) {
|
||||
binding.tvComponentName.text = type.displayName
|
||||
binding.tvStatusSummary.text = when (state) {
|
||||
ComponentState.ACTIVE -> "Available"
|
||||
ComponentState.GREY -> when (type.category) {
|
||||
ComponentCategory.ALWAYS_READY -> "Ready"
|
||||
ComponentCategory.RUNTIME_HOST -> "Not Bound"
|
||||
ComponentCategory.PERIPHERAL -> "Not Connected"
|
||||
}
|
||||
}
|
||||
|
||||
val color = when (state) {
|
||||
ComponentState.ACTIVE -> R.color.rhs_success
|
||||
ComponentState.GREY -> R.color.rhs_timestamp
|
||||
}
|
||||
binding.viewStateIndicator.setBackgroundColor(
|
||||
ContextCompat.getColor(binding.root.context, color)
|
||||
)
|
||||
|
||||
binding.root.alpha = if (state == ComponentState.GREY) 0.6f else 1.0f
|
||||
binding.root.setOnClickListener { onClick(type) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF = object : DiffUtil.ItemCallback<Pair<ComponentType, ComponentState>>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: Pair<ComponentType, ComponentState>,
|
||||
newItem: Pair<ComponentType, ComponentState>
|
||||
) = oldItem.first == newItem.first
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: Pair<ComponentType, ComponentState>,
|
||||
newItem: Pair<ComponentType, ComponentState>
|
||||
) = oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.kiwii.controlpanel.R
|
||||
|
||||
class BalanceBoardOps(context: Context) : BaseOps(context) {
|
||||
|
||||
override fun createView(): View {
|
||||
val layout = verticalLayout()
|
||||
layout.addView(TextView(context).apply {
|
||||
text = "暂无 SDK API"
|
||||
textSize = 12f
|
||||
setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle))
|
||||
setPadding(0, 32, 0, 32)
|
||||
})
|
||||
return layout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.kiwii.controlpanel.R
|
||||
|
||||
abstract class BaseOps(protected val context: Context) {
|
||||
|
||||
abstract fun createView(): View
|
||||
|
||||
protected fun createButton(text: String, onClick: () -> Unit): View {
|
||||
val title = text.toReadableTitle()
|
||||
return LinearLayout(context).apply {
|
||||
tag = TAG_CONTROL_CARD
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
foreground = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)).use {
|
||||
it.getDrawable(0)
|
||||
}
|
||||
setBackgroundResource(R.drawable.bg_rhs_btn_query)
|
||||
setPadding(8.dp(), 8.dp(), 8.dp(), 8.dp())
|
||||
|
||||
addView(TextView(context).apply {
|
||||
this.text = title
|
||||
textSize = 13f
|
||||
gravity = Gravity.CENTER
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(ContextCompat.getColor(context, R.color.rhs_btn_title))
|
||||
})
|
||||
addView(TextView(context).apply {
|
||||
this.text = text
|
||||
textSize = 9f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(ContextCompat.getColor(context, R.color.rhs_btn_subtitle))
|
||||
})
|
||||
|
||||
setOnClickListener { onClick() }
|
||||
}
|
||||
}
|
||||
|
||||
protected fun createSection(title: String): TextView {
|
||||
return TextView(context).apply {
|
||||
this.text = title
|
||||
textSize = 12f
|
||||
setTextColor(ContextCompat.getColor(context, R.color.rhs_title))
|
||||
setPadding(0, 8.dp(), 0, 4.dp())
|
||||
setTypeface(null, Typeface.BOLD)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun createParameterInput(hint: String, inputType: Int): EditText {
|
||||
return EditText(context).apply {
|
||||
tag = TAG_PARAM_INPUT
|
||||
this.hint = hint
|
||||
this.inputType = inputType
|
||||
textSize = 11f
|
||||
setSingleLine(true)
|
||||
setTextColor(ContextCompat.getColor(context, R.color.rhs_stat_value))
|
||||
setHintTextColor(ContextCompat.getColor(context, R.color.rhs_timestamp))
|
||||
setPadding(6.dp(), 0, 6.dp(), 0)
|
||||
minHeight = 36.dp()
|
||||
}
|
||||
}
|
||||
|
||||
protected fun createParameterRow(vararg children: View): LinearLayout {
|
||||
return LinearLayout(context).apply {
|
||||
tag = TAG_PARAM_ROW
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setBackgroundResource(R.drawable.bg_rhs_log_area)
|
||||
setPadding(4.dp(), 4.dp(), 4.dp(), 4.dp())
|
||||
children.forEach { child ->
|
||||
addView(child, LinearLayout.LayoutParams(0, 40.dp(), 1f).apply {
|
||||
setMargins(3.dp(), 0, 3.dp(), 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun createParameterActionRow(action: View, vararg parameters: View): LinearLayout {
|
||||
return LinearLayout(context).apply {
|
||||
tag = TAG_PARAM_ACTION_ROW
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
|
||||
// 输入密集操作需要和右侧控制卡保持同一视觉节奏,避免输入区变成大块空白面板。
|
||||
addView(createParameterRow(*parameters), LinearLayout.LayoutParams(0, PARAM_ACTION_ROW_HEIGHT_DP.dp(), 2f).apply {
|
||||
setMargins(3.dp(), 3.dp(), 3.dp(), 3.dp())
|
||||
})
|
||||
addView(action, LinearLayout.LayoutParams(0, PARAM_ACTION_ROW_HEIGHT_DP.dp(), 1f).apply {
|
||||
setMargins(3.dp(), 3.dp(), 3.dp(), 3.dp())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
protected fun verticalLayout(): LinearLayout {
|
||||
return OpsGridLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fullSpanParams(): LinearLayout.LayoutParams {
|
||||
return LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
setMargins(3.dp(), 3.dp(), 3.dp(), 3.dp())
|
||||
}
|
||||
}
|
||||
|
||||
private fun controlCardParams(): LinearLayout.LayoutParams {
|
||||
return LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f).apply {
|
||||
setMargins(3.dp(), 3.dp(), 3.dp(), 3.dp())
|
||||
}
|
||||
}
|
||||
|
||||
private inner class OpsGridLayout(context: Context) : LinearLayout(context) {
|
||||
private var currentRow: LinearLayout? = null
|
||||
private var currentColumn = 0
|
||||
|
||||
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
|
||||
if (child == null) {
|
||||
super.addView(null, index, params)
|
||||
return
|
||||
}
|
||||
|
||||
if (child.tag == TAG_CONTROL_CARD) {
|
||||
val row = currentRow ?: createControlRow().also { currentRow = it }
|
||||
row.addView(child, controlCardParams())
|
||||
currentColumn += 1
|
||||
if (currentColumn == CONTROL_COLUMNS) {
|
||||
currentRow = null
|
||||
currentColumn = 0
|
||||
}
|
||||
} else {
|
||||
finishPartialRow()
|
||||
styleFullSpanChild(child)
|
||||
super.addView(child, index, params ?: fullSpanParams())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createControlRow(): LinearLayout {
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
minimumHeight = 96.dp()
|
||||
super.addView(this, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishPartialRow() {
|
||||
val row = currentRow ?: return
|
||||
while (currentColumn < CONTROL_COLUMNS) {
|
||||
row.addView(View(context), controlCardParams())
|
||||
currentColumn += 1
|
||||
}
|
||||
currentRow = null
|
||||
currentColumn = 0
|
||||
}
|
||||
|
||||
private fun styleFullSpanChild(child: View) {
|
||||
when (child) {
|
||||
is EditText -> {
|
||||
child.textSize = 12f
|
||||
child.setSingleLine(false)
|
||||
child.minLines = 1
|
||||
child.maxLines = 2
|
||||
child.setTextColor(ContextCompat.getColor(context, R.color.rhs_stat_value))
|
||||
child.setHintTextColor(ContextCompat.getColor(context, R.color.rhs_timestamp))
|
||||
child.setPadding(6.dp(), 4.dp(), 6.dp(), 4.dp())
|
||||
}
|
||||
is Spinner -> {
|
||||
child.minimumHeight = 40.dp()
|
||||
}
|
||||
is LinearLayout -> {
|
||||
if (child.tag == TAG_PARAM_ROW || child.tag == TAG_PARAM_ACTION_ROW) {
|
||||
child.minimumHeight = 48.dp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toReadableTitle(): String {
|
||||
return replace(Regex("^(get|set|submit|request|query|dryRun)"), "")
|
||||
.replace(Regex("([a-z])([A-Z])"), "$1 $2")
|
||||
.replace(Regex("Json$"), "")
|
||||
.trim()
|
||||
.ifEmpty { this }
|
||||
}
|
||||
|
||||
private fun Int.dp(): Int {
|
||||
return (this * context.resources.displayMetrics.density).toInt()
|
||||
}
|
||||
|
||||
private inline fun <T> android.content.res.TypedArray.use(block: (android.content.res.TypedArray) -> T): T {
|
||||
return try {
|
||||
block(this)
|
||||
} finally {
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CONTROL_COLUMNS = 3
|
||||
private const val PARAM_ACTION_ROW_HEIGHT_DP = 88
|
||||
private const val TAG_CONTROL_CARD = "control_card"
|
||||
private const val TAG_PARAM_INPUT = "param_input"
|
||||
private const val TAG_PARAM_ROW = "param_row"
|
||||
private const val TAG_PARAM_ACTION_ROW = "param_action_row"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.kiwii.controlpanel.R
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
||||
|
||||
class CameraOps(
|
||||
context: Context,
|
||||
private val viewModel: ComponentDetailViewModel
|
||||
) : BaseOps(context) {
|
||||
|
||||
override fun createView(): View {
|
||||
val layout = verticalLayout()
|
||||
|
||||
// 预览占位
|
||||
layout.addView(TextView(context).apply {
|
||||
text = "Preview: Pending RuntimeHost debug channel"
|
||||
textSize = 12f
|
||||
setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle))
|
||||
setPadding(0, 0, 0, 16)
|
||||
})
|
||||
|
||||
layout.addView(createSection("SDK Operations"))
|
||||
layout.addView(createButton("getCameraStateJson") {
|
||||
viewModel.executeOperation(ComponentType.CAMERA, "getCameraStateJson") {
|
||||
viewModel.bridgeRepo.getCameraStateJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getPerceptionStateJson") {
|
||||
viewModel.executeOperation(ComponentType.CAMERA, "getPerceptionStateJson") {
|
||||
viewModel.bridgeRepo.getPerceptionStateJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getLatestPose2D") {
|
||||
viewModel.executeOperation(ComponentType.CAMERA, "getLatestPose2D") {
|
||||
viewModel.bridgeRepo.getLatestPose2D()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getLatestPose3D") {
|
||||
viewModel.executeOperation(ComponentType.CAMERA, "getLatestPose3D") {
|
||||
viewModel.bridgeRepo.getLatestPose3D()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getLatestRuntimeStats") {
|
||||
viewModel.executeOperation(ComponentType.CAMERA, "getLatestRuntimeStats") {
|
||||
viewModel.bridgeRepo.getLatestRuntimeStats()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getLatestPoseFrameJson") {
|
||||
viewModel.executeOperation(ComponentType.CAMERA, "getLatestPoseFrameJson") {
|
||||
viewModel.bridgeRepo.getLatestPoseFrameJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getLatestBodyPoseFrameJson") {
|
||||
viewModel.executeOperation(ComponentType.CAMERA, "getLatestBodyPoseFrameJson") {
|
||||
viewModel.bridgeRepo.getLatestBodyPoseFrameJson()
|
||||
}
|
||||
})
|
||||
|
||||
return layout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
||||
|
||||
class DongleOps(
|
||||
context: Context,
|
||||
private val viewModel: ComponentDetailViewModel
|
||||
) : BaseOps(context) {
|
||||
|
||||
override fun createView(): View {
|
||||
val layout = verticalLayout()
|
||||
|
||||
layout.addView(createSection("Dongle Transport"))
|
||||
layout.addView(createButton("getDongleStateJson") {
|
||||
viewModel.executeOperation(ComponentType.DONGLE, "getDongleStateJson") {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
||||
import com.kiwii.controlpanel.util.ImuParser
|
||||
|
||||
class LeftHandleOps(
|
||||
context: Context,
|
||||
private val viewModel: ComponentDetailViewModel
|
||||
) : BaseOps(context) {
|
||||
|
||||
override fun createView(): View {
|
||||
val layout = verticalLayout()
|
||||
|
||||
layout.addView(createSection("IMU"))
|
||||
layout.addView(createButton("getLeftHandleIMULatest") {
|
||||
viewModel.executeOperation(ComponentType.LEFT_HANDLE, "getLeftHandleIMULatest") {
|
||||
val data = viewModel.bridgeRepo.getLeftHandleIMULatest()
|
||||
val frame = ImuParser.parse(data)
|
||||
frame?.let { ImuParser.formatFrame(it) } ?: "No Data"
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getLeftHandleIMUQueue") {
|
||||
viewModel.executeOperation(ComponentType.LEFT_HANDLE, "getLeftHandleIMUQueue") {
|
||||
val queue = viewModel.bridgeRepo.getLeftHandleIMUQueue()
|
||||
val frames = ImuParser.parseQueue(queue)
|
||||
frames.joinToString("\n") { ImuParser.formatFrame(it) }.ifEmpty { "No Data" }
|
||||
}
|
||||
})
|
||||
|
||||
val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
|
||||
val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT)
|
||||
val heStreamAction = createButton("submitLeftHeStream") {
|
||||
val sid = streamIdInput.text.toString().toIntOrNull() ?: 0
|
||||
val payload = payloadInput.text.toString()
|
||||
viewModel.executeOperation(ComponentType.LEFT_HANDLE, "submitLeftHeStream", "streamId=$sid,payload=$payload") {
|
||||
viewModel.bridgeRepo.submitLeftHeStream(sid, payload)
|
||||
}
|
||||
}
|
||||
layout.addView(createParameterActionRow(heStreamAction, streamIdInput, payloadInput))
|
||||
|
||||
layout.addView(createSection("Handle State"))
|
||||
layout.addView(createButton("getHandleStateJson") {
|
||||
viewModel.executeOperation(ComponentType.LEFT_HANDLE, "getHandleStateJson") {
|
||||
viewModel.bridgeRepo.getHandleStateJson()
|
||||
}
|
||||
})
|
||||
|
||||
return layout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Spinner
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
||||
|
||||
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)"
|
||||
)
|
||||
|
||||
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("Training Mode"))
|
||||
val modeSpinner = Spinner(context).apply {
|
||||
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, trainingModes)
|
||||
}
|
||||
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))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
layout.addView(createParameterActionRow(queryModeAction, axisInput))
|
||||
|
||||
layout.addView(createSection("State Queries"))
|
||||
layout.addView(createButton("getLatestMotorStateJson") {
|
||||
viewModel.executeOperation(ComponentType.MOTOR, "getLatestMotorStateJson") {
|
||||
viewModel.bridgeRepo.getLatestMotorStateJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getMotorControlStateJson") {
|
||||
viewModel.executeOperation(ComponentType.MOTOR, "getMotorControlStateJson") {
|
||||
viewModel.bridgeRepo.getMotorControlStateJson()
|
||||
}
|
||||
})
|
||||
|
||||
return layout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
||||
import com.kiwii.controlpanel.util.ImuParser
|
||||
|
||||
class RightHandleOps(
|
||||
context: Context,
|
||||
private val viewModel: ComponentDetailViewModel
|
||||
) : BaseOps(context) {
|
||||
|
||||
override fun createView(): View {
|
||||
val layout = verticalLayout()
|
||||
|
||||
layout.addView(createSection("IMU"))
|
||||
layout.addView(createButton("getRightHandleIMULatest") {
|
||||
viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "getRightHandleIMULatest") {
|
||||
val data = viewModel.bridgeRepo.getRightHandleIMULatest()
|
||||
val frame = ImuParser.parse(data)
|
||||
frame?.let { ImuParser.formatFrame(it) } ?: "No Data"
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getRightHandleIMUQueue") {
|
||||
viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "getRightHandleIMUQueue") {
|
||||
val queue = viewModel.bridgeRepo.getRightHandleIMUQueue()
|
||||
val frames = ImuParser.parseQueue(queue)
|
||||
frames.joinToString("\n") { ImuParser.formatFrame(it) }.ifEmpty { "No Data" }
|
||||
}
|
||||
})
|
||||
|
||||
val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
|
||||
val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT)
|
||||
val heStreamAction = createButton("submitRightHeStream") {
|
||||
val sid = streamIdInput.text.toString().toIntOrNull() ?: 0
|
||||
val payload = payloadInput.text.toString()
|
||||
viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "submitRightHeStream", "streamId=$sid,payload=$payload") {
|
||||
viewModel.bridgeRepo.submitRightHeStream(sid, payload)
|
||||
}
|
||||
}
|
||||
layout.addView(createParameterActionRow(heStreamAction, streamIdInput, payloadInput))
|
||||
|
||||
layout.addView(createSection("Handle State"))
|
||||
layout.addView(createButton("getHandleStateJson") {
|
||||
viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "getHandleStateJson") {
|
||||
viewModel.bridgeRepo.getHandleStateJson()
|
||||
}
|
||||
})
|
||||
|
||||
return layout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
||||
|
||||
class RuntimeHostStatusOps(
|
||||
context: Context,
|
||||
private val viewModel: ComponentDetailViewModel
|
||||
) : BaseOps(context) {
|
||||
|
||||
override fun createView(): View {
|
||||
val layout = verticalLayout()
|
||||
|
||||
layout.addView(createSection("Lifecycle"))
|
||||
layout.addView(createButton("Bind") {
|
||||
val activity = context as? Activity ?: return@createButton
|
||||
viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "bind") {
|
||||
viewModel.bridgeRepo.bind(activity)
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("Unbind") {
|
||||
viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "unbind") {
|
||||
viewModel.bridgeRepo.unbind()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("Ping") {
|
||||
viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "ping") {
|
||||
viewModel.bridgeRepo.ping()
|
||||
}
|
||||
})
|
||||
|
||||
layout.addView(createSection("State Queries"))
|
||||
layout.addView(createButton("getRuntimeStateJson") {
|
||||
viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getRuntimeStateJson") {
|
||||
viewModel.bridgeRepo.getRuntimeStateJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getHealthStateJson") {
|
||||
viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getHealthStateJson") {
|
||||
viewModel.bridgeRepo.getHealthStateJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getDebugSnapshotJson") {
|
||||
viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getDebugSnapshotJson") {
|
||||
viewModel.bridgeRepo.getDebugSnapshotJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getRuntimeLifecycleStateJson") {
|
||||
viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getRuntimeLifecycleStateJson") {
|
||||
viewModel.bridgeRepo.getRuntimeLifecycleStateJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("requestRuntimeWarmStart") {
|
||||
viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "requestRuntimeWarmStart") {
|
||||
viewModel.bridgeRepo.requestRuntimeWarmStart()
|
||||
}
|
||||
})
|
||||
|
||||
return layout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.kiwii.controlpanel.R
|
||||
import com.kiwii.controlpanel.logging.SessionLogger
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class SessionLogOps(context: Context) : BaseOps(context) {
|
||||
|
||||
override fun createView(): View {
|
||||
val layout = verticalLayout()
|
||||
|
||||
val logView = TextView(context).apply {
|
||||
textSize = 11f
|
||||
typeface = android.graphics.Typeface.MONOSPACE
|
||||
setTextColor(ContextCompat.getColor(context, R.color.rhs_stat_value))
|
||||
setPadding(0, 8, 0, 0)
|
||||
}
|
||||
|
||||
fun refreshLog() {
|
||||
val entries = SessionLogger.getEntries().reversed()
|
||||
val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
logView.text = entries.joinToString("\n") { entry ->
|
||||
val time = sdf.format(Date(entry.timestamp))
|
||||
val comp = entry.component?.displayName ?: "GLOBAL"
|
||||
"$time [$comp] ${entry.action}: ${entry.result?.take(60) ?: ""}"
|
||||
}.ifEmpty { "No logs" }
|
||||
}
|
||||
|
||||
layout.addView(createButton("Refresh") { refreshLog() })
|
||||
layout.addView(createButton("Export") {
|
||||
val uri = SessionLogger.export(context)
|
||||
if (uri != null) {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Export Log"))
|
||||
} else {
|
||||
Toast.makeText(context, "Export failed", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("Clear") {
|
||||
SessionLogger.clear()
|
||||
refreshLog()
|
||||
})
|
||||
|
||||
layout.addView(logView)
|
||||
refreshLog()
|
||||
|
||||
return layout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.kiwii.controlpanel.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
|
||||
|
||||
class TelemetrySafetyOps(
|
||||
context: Context,
|
||||
private val viewModel: ComponentDetailViewModel
|
||||
) : BaseOps(context) {
|
||||
|
||||
override fun createView(): View {
|
||||
val layout = verticalLayout()
|
||||
|
||||
layout.addView(createSection("State Queries"))
|
||||
layout.addView(createButton("getTelemetryStateJson") {
|
||||
viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "getTelemetryStateJson") {
|
||||
viewModel.bridgeRepo.getTelemetryStateJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getSafetyStateJson") {
|
||||
viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "getSafetyStateJson") {
|
||||
viewModel.bridgeRepo.getSafetyStateJson()
|
||||
}
|
||||
})
|
||||
layout.addView(createButton("getDeviceCommandStateJson") {
|
||||
viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "getDeviceCommandStateJson") {
|
||||
viewModel.bridgeRepo.getDeviceCommandStateJson()
|
||||
}
|
||||
})
|
||||
|
||||
layout.addView(createSection("Dry Run"))
|
||||
val commandInput = createParameterInput("Command (JSON string)", android.text.InputType.TYPE_CLASS_TEXT)
|
||||
val dryRunAction = createButton("dryRunSafetyGate") {
|
||||
val cmd = commandInput.text.toString()
|
||||
viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "dryRunSafetyGate", "command=$cmd") {
|
||||
viewModel.bridgeRepo.dryRunSafetyGate(cmd)
|
||||
}
|
||||
}
|
||||
layout.addView(createParameterActionRow(dryRunAction, commandInput))
|
||||
layout.addView(createButton("submitDeviceCommandDryRun") {
|
||||
val cmd = commandInput.text.toString()
|
||||
viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "submitDeviceCommandDryRun", "command=$cmd") {
|
||||
viewModel.bridgeRepo.submitDeviceCommandDryRun(cmd)
|
||||
}
|
||||
})
|
||||
|
||||
return layout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.kiwii.controlpanel.ui.detail
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.kiwii.controlpanel.R
|
||||
import com.kiwii.controlpanel.databinding.FragmentComponentDetailBinding
|
||||
import com.kiwii.controlpanel.logging.LogEntry
|
||||
import com.kiwii.controlpanel.logging.SessionLogger
|
||||
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.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class ComponentDetailFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentComponentDetailBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: ComponentDetailViewModel by viewModels()
|
||||
|
||||
private lateinit var componentType: ComponentType
|
||||
private lateinit var stateAdapter: StatAdapter
|
||||
private lateinit var logAdapter: LogAdapter
|
||||
private var latestStateText: String = "No state data"
|
||||
private var latestResultText: String = "—"
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
componentType = ComponentType.valueOf(
|
||||
arguments?.getString("componentType") ?: ComponentType.RUNTIME_HOST_STATUS.name
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentComponentDetailBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel.setComponentType(componentType)
|
||||
updateTitle()
|
||||
|
||||
setupMenu()
|
||||
setupStateAndLog()
|
||||
loadOpsView()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updateTitle()
|
||||
}
|
||||
|
||||
private fun updateTitle() {
|
||||
(requireActivity() as? AppCompatActivity)?.supportActionBar?.title = componentType.displayName
|
||||
requireActivity().title = componentType.displayName
|
||||
}
|
||||
|
||||
private fun setupMenu() {
|
||||
requireActivity().addMenuProvider(object : MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.menu_detail, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return when (menuItem.itemId) {
|
||||
R.id.action_record -> {
|
||||
recordSnapshot()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
}
|
||||
|
||||
private fun setupStateAndLog() {
|
||||
stateAdapter = StatAdapter()
|
||||
// 通用设备状态字段比 RuntimeHost 更长,3 列能保留卡片摘要感,同时避免关键信息被过早截断。
|
||||
binding.rvStateStats.layoutManager = GridLayoutManager(requireContext(), 3)
|
||||
binding.rvStateStats.adapter = stateAdapter
|
||||
|
||||
logAdapter = LogAdapter()
|
||||
binding.rvLogEntries.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.rvLogEntries.adapter = logAdapter
|
||||
}
|
||||
|
||||
private fun loadOpsView() {
|
||||
val opsView = when (componentType) {
|
||||
ComponentType.RUNTIME_HOST_STATUS -> RuntimeHostStatusOps(requireContext(), viewModel)
|
||||
ComponentType.CAMERA -> CameraOps(requireContext(), viewModel)
|
||||
ComponentType.LEFT_HANDLE -> LeftHandleOps(requireContext(), viewModel)
|
||||
ComponentType.RIGHT_HANDLE -> RightHandleOps(requireContext(), viewModel)
|
||||
ComponentType.BALANCE_BOARD -> BalanceBoardOps(requireContext())
|
||||
ComponentType.DONGLE -> DongleOps(requireContext(), viewModel)
|
||||
ComponentType.MOTOR -> MotorOps(requireContext(), viewModel)
|
||||
ComponentType.TELEMETRY_SAFETY -> TelemetrySafetyOps(requireContext(), viewModel)
|
||||
ComponentType.SESSION_LOG -> SessionLogOps(requireContext())
|
||||
}.createView()
|
||||
|
||||
binding.opsContainer.removeAllViews()
|
||||
binding.opsContainer.addView(
|
||||
opsView,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch { observeState() }
|
||||
launch { observeResult() }
|
||||
launch { observeLogs() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun observeState() {
|
||||
viewModel.stateSnapshot.collect { snapshot ->
|
||||
if (snapshot != null) {
|
||||
latestStateText = JsonFormatter.format(snapshot.data)
|
||||
val items = parseStateItems(snapshot.data)
|
||||
.ifEmpty { listOf(StatItem("State", "Updated")) }
|
||||
stateAdapter.submitList(items)
|
||||
val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
val source = if (snapshot.isDebugChannel) "[Debug]" else "[SDK]"
|
||||
binding.tvStateUpdatedAt.text = "Updated ${sdf.format(Date(snapshot.updatedAt))} $source"
|
||||
} else {
|
||||
latestStateText = "No state data"
|
||||
stateAdapter.submitList(emptyList())
|
||||
binding.tvStateUpdatedAt.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun observeResult() {
|
||||
viewModel.operationResult.collect { result ->
|
||||
when (result) {
|
||||
is OperationResult.Success<*> -> {
|
||||
latestResultText = formatResultData(result.data)
|
||||
}
|
||||
is OperationResult.Error -> {
|
||||
latestResultText = "${result.exception.javaClass.simpleName}: ${result.exception.message}"
|
||||
}
|
||||
is OperationResult.NotBound -> {
|
||||
latestResultText = "NOT_BOUND"
|
||||
}
|
||||
is OperationResult.NoData -> {
|
||||
latestResultText = "No Data"
|
||||
}
|
||||
null -> {
|
||||
latestResultText = "—"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseStateItems(json: String): List<StatItem> {
|
||||
return if (componentType == ComponentType.MOTOR) {
|
||||
MotorRuntimeStateParser.parse(json).ifEmpty {
|
||||
RuntimeHostStatusParser.parse(json, viewModel.bridgeRepo.isBound())
|
||||
}
|
||||
} else {
|
||||
RuntimeHostStatusParser.parse(json, viewModel.bridgeRepo.isBound())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun observeLogs() {
|
||||
viewModel.logEntries.collect { entries ->
|
||||
val visibleEntries = entries.takeLast(80).ifEmpty {
|
||||
listOf(LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
component = componentType,
|
||||
action = "No logs yet",
|
||||
params = null,
|
||||
result = null
|
||||
))
|
||||
}.toList()
|
||||
logAdapter.submitList(visibleEntries) {
|
||||
if (visibleEntries.isNotEmpty()) {
|
||||
binding.rvLogEntries.scrollToPosition(visibleEntries.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatResultData(data: Any?): String {
|
||||
return when (data) {
|
||||
is String -> JsonFormatter.format(data)
|
||||
is FloatArray -> data.contentToString()
|
||||
is Array<*> -> (data as? Array<FloatArray>)?.joinToString("\n") { it.contentToString() } ?: data.contentToString()
|
||||
else -> data.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordSnapshot() {
|
||||
SessionLogger.log(LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
component = componentType,
|
||||
action = "SNAPSHOT",
|
||||
params = null,
|
||||
result = "state=$latestStateText|result=$latestResultText"
|
||||
))
|
||||
Toast.makeText(requireContext(), "Recorded", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.kiwii.controlpanel.ui.detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.kiwii.controlpanel.data.AarProxyStateSource
|
||||
import com.kiwii.controlpanel.data.BridgeRepository
|
||||
import com.kiwii.controlpanel.data.RuntimeStateRepository
|
||||
import com.kiwii.controlpanel.data.StatePoller
|
||||
import com.kiwii.controlpanel.logging.LogEntry
|
||||
import com.kiwii.controlpanel.logging.SessionLogger
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.model.OperationResult
|
||||
import com.kiwii.controlpanel.model.StateSnapshot
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ComponentDetailViewModel : ViewModel() {
|
||||
|
||||
val bridgeRepo = BridgeRepository()
|
||||
private val runtimeStateRepo = RuntimeStateRepository(AarProxyStateSource(bridgeRepo))
|
||||
private val statePoller = StatePoller(runtimeStateRepo, bridgeRepo, SessionLogger)
|
||||
|
||||
private val _operationResult = MutableStateFlow<OperationResult<*>?>(null)
|
||||
val operationResult: StateFlow<OperationResult<*>?> = _operationResult
|
||||
|
||||
private val _componentType = MutableStateFlow(ComponentType.RUNTIME_HOST_STATUS)
|
||||
|
||||
val stateSnapshot: StateFlow<StateSnapshot?> = _componentType
|
||||
.combine(statePoller.states) { type, map -> map[type] }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
private val _logEntries = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||
val logEntries: StateFlow<List<LogEntry>> = _logEntries
|
||||
|
||||
init {
|
||||
statePoller.startPolling()
|
||||
viewModelScope.launch {
|
||||
while (isActive) {
|
||||
_logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setComponentType(type: ComponentType) {
|
||||
_componentType.value = type
|
||||
}
|
||||
|
||||
fun <T> executeOperation(
|
||||
componentType: ComponentType,
|
||||
actionName: String,
|
||||
params: String? = null,
|
||||
block: () -> T
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = bridgeRepo.callAsync(block)
|
||||
_operationResult.value = result
|
||||
|
||||
val resultStr = when (result) {
|
||||
is OperationResult.Success<*> -> result.data.toString()
|
||||
is OperationResult.Error -> "ERROR: ${result.exception.message}"
|
||||
is OperationResult.NotBound -> result.defaultValue
|
||||
is OperationResult.NoData -> "NO_DATA"
|
||||
}
|
||||
|
||||
SessionLogger.log(LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
component = componentType,
|
||||
action = actionName,
|
||||
params = params,
|
||||
result = resultStr
|
||||
))
|
||||
|
||||
_logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
statePoller.stopPolling()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.kiwii.controlpanel.ui.detail
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.kiwii.controlpanel.R
|
||||
|
||||
enum class ControlStyle { LIFECYCLE, QUERY, WARMSTART }
|
||||
|
||||
data class ControlItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val style: ControlStyle,
|
||||
val spanSize: Int = 1
|
||||
)
|
||||
|
||||
class ControlAdapter(
|
||||
private val items: List<ControlItem>,
|
||||
private val onClick: (ControlItem) -> Unit
|
||||
) : RecyclerView.Adapter<ControlAdapter.VH>() {
|
||||
|
||||
var itemHeight: Int = 0
|
||||
|
||||
class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val root: View = view.findViewById(R.id.control_root)
|
||||
val title: TextView = view.findViewById(R.id.tv_title)
|
||||
val subtitle: TextView = view.findViewById(R.id.tv_subtitle)
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_rhs_control, parent, false)
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val item = items[position]
|
||||
val lp = holder.itemView.layoutParams as GridLayoutManager.LayoutParams
|
||||
if (itemHeight > 0) {
|
||||
lp.height = itemHeight
|
||||
}
|
||||
val margin = (3 * holder.itemView.resources.displayMetrics.density).toInt()
|
||||
lp.setMargins(margin, margin, margin, margin)
|
||||
holder.itemView.layoutParams = lp
|
||||
|
||||
holder.title.text = item.title
|
||||
holder.subtitle.text = item.subtitle
|
||||
|
||||
val bgRes = when (item.style) {
|
||||
ControlStyle.LIFECYCLE -> R.drawable.bg_rhs_btn_lifecycle
|
||||
ControlStyle.QUERY -> R.drawable.bg_rhs_btn_query
|
||||
ControlStyle.WARMSTART -> R.drawable.bg_rhs_btn_warmstart
|
||||
}
|
||||
holder.root.setBackgroundResource(bgRes)
|
||||
|
||||
when (item.style) {
|
||||
ControlStyle.WARMSTART -> {
|
||||
holder.title.setTextColor(holder.itemView.context.getColor(R.color.rhs_warmstart_title))
|
||||
holder.subtitle.setTextColor(holder.itemView.context.getColor(R.color.rhs_warmstart_subtitle))
|
||||
}
|
||||
else -> {
|
||||
holder.title.setTextColor(holder.itemView.context.getColor(R.color.rhs_btn_title))
|
||||
holder.subtitle.setTextColor(holder.itemView.context.getColor(R.color.rhs_btn_subtitle))
|
||||
}
|
||||
}
|
||||
|
||||
holder.root.setOnClickListener { onClick(item) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.kiwii.controlpanel.ui.detail
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.kiwii.controlpanel.R
|
||||
import com.kiwii.controlpanel.logging.LogEntry
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class LogAdapter : ListAdapter<LogEntry, LogAdapter.VH>(DIFF) {
|
||||
|
||||
private val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
|
||||
class VH(val textView: TextView) : RecyclerView.ViewHolder(textView)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_rhs_log, parent, false) as TextView
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val entry = getItem(position)
|
||||
val type = LogColorizer.classify(entry)
|
||||
val tag = when (type) {
|
||||
LogColorizer.EntryType.TX -> "[TX]"
|
||||
LogColorizer.EntryType.RX -> "[RX]"
|
||||
LogColorizer.EntryType.SYS -> "[SYS]"
|
||||
}
|
||||
val color = when (type) {
|
||||
LogColorizer.EntryType.TX -> R.color.rhs_tx_blue
|
||||
LogColorizer.EntryType.RX -> R.color.rhs_rx_green
|
||||
LogColorizer.EntryType.SYS -> R.color.rhs_sys_purple
|
||||
}
|
||||
|
||||
val time = sdf.format(Date(entry.timestamp))
|
||||
val result = entry.result ?: ""
|
||||
val suffix = if (result.isNotEmpty()) " => $result" else ""
|
||||
holder.textView.text = "$time $tag ${entry.action}$suffix"
|
||||
holder.textView.setTextColor(holder.itemView.context.getColor(color))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF = object : DiffUtil.ItemCallback<LogEntry>() {
|
||||
override fun areItemsTheSame(a: LogEntry, b: LogEntry) =
|
||||
a.timestamp == b.timestamp && a.action == b.action
|
||||
override fun areContentsTheSame(a: LogEntry, b: LogEntry) = a == b
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.kiwii.controlpanel.ui.detail
|
||||
|
||||
import com.kiwii.controlpanel.logging.LogEntry
|
||||
|
||||
/** 根据 LogEntry 内容分类为 TX/RX/SYS,供 LogAdapter 设置颜色 */
|
||||
object LogColorizer {
|
||||
|
||||
enum class EntryType { TX, RX, SYS }
|
||||
|
||||
fun classify(entry: LogEntry): EntryType {
|
||||
if (entry.isStateChange) return EntryType.SYS
|
||||
val action = entry.action.lowercase()
|
||||
if (action.contains("poll") || action.contains("stream") || action == "snapshot" || action == "connection") {
|
||||
return EntryType.SYS
|
||||
}
|
||||
val result = entry.result
|
||||
if (result != null && !result.startsWith("ERROR")) {
|
||||
return EntryType.RX
|
||||
}
|
||||
return EntryType.TX
|
||||
}
|
||||
}
|
||||
@@ -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,213 @@
|
||||
package com.kiwii.controlpanel.ui.detail
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.kiwii.controlpanel.R
|
||||
import com.kiwii.controlpanel.databinding.FragmentRuntimeHostStatusBinding
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class RuntimeHostStatusFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentRuntimeHostStatusBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: ComponentDetailViewModel by viewModels()
|
||||
|
||||
private lateinit var statAdapter: StatAdapter
|
||||
private lateinit var logAdapter: LogAdapter
|
||||
private lateinit var controlAdapter: ControlAdapter
|
||||
|
||||
private var logStreamActive = true
|
||||
|
||||
private val controlItems = listOf(
|
||||
ControlItem("bind", "Bind", "bind(activity)", ControlStyle.LIFECYCLE),
|
||||
ControlItem("unbind", "Unbind", "unbind()", ControlStyle.LIFECYCLE),
|
||||
ControlItem("ping", "Ping", "ping()", ControlStyle.LIFECYCLE),
|
||||
ControlItem("getRuntimeStateJson", "Runtime State", "getRuntimeStateJson", ControlStyle.QUERY),
|
||||
ControlItem("getHealthStateJson", "Health", "getHealthStateJson", ControlStyle.QUERY),
|
||||
ControlItem("getDebugSnapshotJson", "Debug", "getDebugSnapshotJson", ControlStyle.QUERY),
|
||||
ControlItem("getRuntimeLifecycleStateJson", "Lifecycle", "getRuntimeLifecycleStateJson", ControlStyle.QUERY),
|
||||
ControlItem("requestRuntimeWarmStart", "Warm Start", "requestRuntimeWarmStart", ControlStyle.WARMSTART, spanSize = 2),
|
||||
)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentRuntimeHostStatusBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.setComponentType(ComponentType.RUNTIME_HOST_STATUS)
|
||||
requireActivity().title = ComponentType.RUNTIME_HOST_STATUS.displayName
|
||||
|
||||
setupStats()
|
||||
setupLog()
|
||||
setupControls()
|
||||
setupLogToggle()
|
||||
observeState()
|
||||
observeLogs()
|
||||
}
|
||||
|
||||
private fun setupStats() {
|
||||
statAdapter = StatAdapter()
|
||||
binding.rvStats.layoutManager = GridLayoutManager(requireContext(), 4)
|
||||
binding.rvStats.adapter = statAdapter
|
||||
}
|
||||
|
||||
private fun setupLog() {
|
||||
logAdapter = LogAdapter()
|
||||
binding.rvLog.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.rvLog.adapter = logAdapter
|
||||
}
|
||||
|
||||
private fun setupControls() {
|
||||
val spanCount = 3
|
||||
val layoutManager = GridLayoutManager(requireContext(), spanCount)
|
||||
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
return if (position < controlItems.size) controlItems[position].spanSize else 1
|
||||
}
|
||||
}
|
||||
|
||||
controlAdapter = ControlAdapter(controlItems) { item ->
|
||||
handleControlClick(item)
|
||||
}
|
||||
|
||||
binding.rvControls.layoutManager = layoutManager
|
||||
binding.rvControls.adapter = controlAdapter
|
||||
|
||||
binding.rvControls.post {
|
||||
val rowCount = calculateRowCount()
|
||||
if (rowCount > 0) {
|
||||
val totalHeight = binding.rvControls.height
|
||||
val spacing = (3 * resources.displayMetrics.density).toInt()
|
||||
// 每行高度 = (总高 - 行间距总和) / 行数
|
||||
val itemHeight = (totalHeight - spacing * (rowCount + 1)) / rowCount
|
||||
controlAdapter.itemHeight = itemHeight
|
||||
controlAdapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateRowCount(): Int {
|
||||
var row = 0
|
||||
var spanUsed = 0
|
||||
for (item in controlItems) {
|
||||
if (spanUsed + item.spanSize > 3) {
|
||||
row++
|
||||
spanUsed = 0
|
||||
}
|
||||
spanUsed += item.spanSize
|
||||
}
|
||||
if (spanUsed > 0) row++
|
||||
return row
|
||||
}
|
||||
|
||||
private fun handleControlClick(item: ControlItem) {
|
||||
when (item.id) {
|
||||
"bind" -> {
|
||||
val activity = context as? Activity ?: return
|
||||
viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "bind") {
|
||||
viewModel.bridgeRepo.bind(activity)
|
||||
}
|
||||
}
|
||||
"unbind" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "unbind") {
|
||||
viewModel.bridgeRepo.unbind()
|
||||
}
|
||||
"ping" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "ping") {
|
||||
viewModel.bridgeRepo.ping()
|
||||
}
|
||||
"getRuntimeStateJson" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getRuntimeStateJson") {
|
||||
viewModel.bridgeRepo.getRuntimeStateJson()
|
||||
}
|
||||
"getHealthStateJson" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getHealthStateJson") {
|
||||
viewModel.bridgeRepo.getHealthStateJson()
|
||||
}
|
||||
"getDebugSnapshotJson" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getDebugSnapshotJson") {
|
||||
viewModel.bridgeRepo.getDebugSnapshotJson()
|
||||
}
|
||||
"getRuntimeLifecycleStateJson" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getRuntimeLifecycleStateJson") {
|
||||
viewModel.bridgeRepo.getRuntimeLifecycleStateJson()
|
||||
}
|
||||
"requestRuntimeWarmStart" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "requestRuntimeWarmStart") {
|
||||
viewModel.bridgeRepo.requestRuntimeWarmStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupLogToggle() {
|
||||
binding.btnLogToggle.setOnClickListener {
|
||||
logStreamActive = !logStreamActive
|
||||
binding.btnLogToggle.text = if (logStreamActive) "Stop" else "Start"
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeState() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.stateSnapshot.collect { snapshot ->
|
||||
if (snapshot != null) {
|
||||
val isBound = viewModel.bridgeRepo.isBound()
|
||||
val items = RuntimeHostStatusParser.parse(snapshot.data, isBound)
|
||||
statAdapter.submitList(items)
|
||||
updateBadge(isBound)
|
||||
|
||||
val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
val source = if (snapshot.isDebugChannel) "[Debug]" else "[SDK]"
|
||||
binding.tvUpdatedAt.text = "Updated ${sdf.format(Date(snapshot.updatedAt))} $source"
|
||||
} else {
|
||||
// poller 返回空 map → 未绑定或无数据
|
||||
statAdapter.submitList(emptyList())
|
||||
updateBadge(false)
|
||||
binding.tvUpdatedAt.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBadge(isBound: Boolean) {
|
||||
if (isBound) {
|
||||
binding.tvBadge.text = "BOUND"
|
||||
binding.tvBadge.setBackgroundResource(R.drawable.bg_rhs_badge_bound)
|
||||
binding.tvBadge.setTextColor(requireContext().getColor(R.color.rhs_badge_text))
|
||||
} else {
|
||||
binding.tvBadge.text = "UNBOUND"
|
||||
binding.tvBadge.setBackgroundResource(R.drawable.bg_rhs_badge_unbound)
|
||||
binding.tvBadge.setTextColor(requireContext().getColor(R.color.result_not_bound))
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeLogs() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.logEntries.collect { entries ->
|
||||
if (!logStreamActive) return@collect
|
||||
logAdapter.submitList(entries.toList()) {
|
||||
if (entries.isNotEmpty()) {
|
||||
binding.rvLog.scrollToPosition(entries.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.kiwii.controlpanel.ui.detail
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 状态卡片中的一个指标项
|
||||
* @param label 指标名称(JSON key 的可读形式)
|
||||
* @param value 主要显示值
|
||||
* @param detail 辅助说明(可为空)
|
||||
*/
|
||||
data class StatItem(
|
||||
val label: String,
|
||||
val value: String,
|
||||
val detail: String = ""
|
||||
)
|
||||
|
||||
/** 从 RuntimeState JSON 中动态提取所有顶层字段为 StatItem 列表 */
|
||||
object RuntimeHostStatusParser {
|
||||
|
||||
fun parse(json: String, isBound: Boolean): List<StatItem> {
|
||||
val items = mutableListOf<StatItem>()
|
||||
|
||||
val obj = try {
|
||||
JSONObject(json)
|
||||
} catch (_: Exception) {
|
||||
return items
|
||||
}
|
||||
|
||||
val keys = obj.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val raw = obj.opt(key) ?: continue
|
||||
val item = when {
|
||||
raw is JSONObject -> StatItem(
|
||||
label = humanize(key),
|
||||
value = raw.optString("state", raw.optString("value", "…")).compactValue(),
|
||||
detail = raw.optString("detail", "")
|
||||
)
|
||||
else -> StatItem(
|
||||
label = humanize(key),
|
||||
value = raw.toString().compactValue()
|
||||
)
|
||||
}
|
||||
items.add(item)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/** camelCase / snake_case → 可读标签 */
|
||||
private fun humanize(key: String): String {
|
||||
return key
|
||||
.replace(Regex("([a-z])([A-Z])"), "$1 $2")
|
||||
.replace("_", " ")
|
||||
.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
private fun String.compactValue(): String {
|
||||
return replace("\"", "")
|
||||
.let { if (it.length > 16) "${it.take(14)}..." else it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.kiwii.controlpanel.ui.detail
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.kiwii.controlpanel.R
|
||||
|
||||
class StatAdapter : ListAdapter<StatItem, StatAdapter.VH>(DIFF) {
|
||||
|
||||
class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val label: TextView = view.findViewById(R.id.tv_label)
|
||||
val value: TextView = view.findViewById(R.id.tv_value)
|
||||
val detail: TextView = view.findViewById(R.id.tv_detail)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_rhs_stat, parent, false)
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val item = getItem(position)
|
||||
val margin = (3 * holder.itemView.resources.displayMetrics.density).toInt()
|
||||
val lp = holder.itemView.layoutParams as GridLayoutManager.LayoutParams
|
||||
lp.setMargins(margin, margin, margin, margin)
|
||||
holder.itemView.layoutParams = lp
|
||||
|
||||
holder.label.text = item.label
|
||||
holder.value.text = item.value
|
||||
if (item.detail.isNotEmpty()) {
|
||||
holder.detail.text = item.detail
|
||||
holder.detail.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.detail.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF = object : DiffUtil.ItemCallback<StatItem>() {
|
||||
override fun areItemsTheSame(a: StatItem, b: StatItem) = a.label == b.label
|
||||
override fun areContentsTheSame(a: StatItem, b: StatItem) = a == b
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.kiwii.controlpanel.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.kiwii.controlpanel.R
|
||||
import com.kiwii.controlpanel.databinding.FragmentHomeBinding
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import com.kiwii.controlpanel.ui.adapter.ComponentCardAdapter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HomeFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentHomeBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by viewModels()
|
||||
|
||||
private lateinit var adapter: ComponentCardAdapter
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentHomeBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
adapter = ComponentCardAdapter { componentType ->
|
||||
navigateToDetail(componentType)
|
||||
}
|
||||
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.componentStates.collect { states ->
|
||||
adapter.submitList(states)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToDetail(type: ComponentType) {
|
||||
val bundle = Bundle().apply {
|
||||
putString("componentType", type.name)
|
||||
}
|
||||
findNavController().navigate(R.id.action_home_to_detail, bundle)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.kiwii.controlpanel.ui.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.kiwii.controlpanel.data.BridgeRepository
|
||||
import com.kiwii.controlpanel.data.ComponentRegistry
|
||||
import com.kiwii.controlpanel.logging.SessionLogger
|
||||
import com.kiwii.controlpanel.model.ComponentState
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val bridgeRepo = BridgeRepository()
|
||||
private val registry = ComponentRegistry(bridgeRepo, viewModelScope, SessionLogger)
|
||||
|
||||
init {
|
||||
registry.startDetection()
|
||||
}
|
||||
|
||||
val componentStates: StateFlow<List<Pair<ComponentType, ComponentState>>> =
|
||||
registry.availability.map { map ->
|
||||
ComponentType.entries.map { type ->
|
||||
type to (map[type] ?: ComponentState.GREY)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.kiwii.controlpanel.util
|
||||
|
||||
data class ImuFrame(
|
||||
val seqId: Int,
|
||||
val dtUs: Int,
|
||||
val qw: Float,
|
||||
val qx: Float,
|
||||
val qy: Float,
|
||||
val qz: Float,
|
||||
val lax: Float,
|
||||
val lay: Float,
|
||||
val laz: Float
|
||||
)
|
||||
|
||||
object ImuParser {
|
||||
|
||||
/**
|
||||
* 解析 float[9] IMU 帧: [seq_id, dt_us, qw, qx, qy, qz, lax, lay, laz]
|
||||
*/
|
||||
fun parse(data: FloatArray): ImuFrame? {
|
||||
if (data.size < 9) return null
|
||||
return ImuFrame(
|
||||
seqId = data[0].toInt(),
|
||||
dtUs = data[1].toInt(),
|
||||
qw = data[2],
|
||||
qx = data[3],
|
||||
qy = data[4],
|
||||
qz = data[5],
|
||||
lax = data[6],
|
||||
lay = data[7],
|
||||
laz = data[8]
|
||||
)
|
||||
}
|
||||
|
||||
fun parseQueue(queue: Array<FloatArray>): List<ImuFrame> {
|
||||
return queue.mapNotNull { parse(it) }
|
||||
}
|
||||
|
||||
fun formatFrame(frame: ImuFrame): String {
|
||||
return "seq=${frame.seqId} dt=${frame.dtUs}us " +
|
||||
"q=(${f(frame.qw)},${f(frame.qx)},${f(frame.qy)},${f(frame.qz)}) " +
|
||||
"acc=(${f(frame.lax)},${f(frame.lay)},${f(frame.laz)})"
|
||||
}
|
||||
|
||||
private fun f(v: Float): String = "%.3f".format(v)
|
||||
}
|
||||
|
||||
/**
|
||||
* IMU 流统计器 — 检测 seq 跳变和帧间隔异常
|
||||
*/
|
||||
class ImuStats {
|
||||
private var lastSeqId = -1
|
||||
private var frameCount = 0
|
||||
private var dropCount = 0
|
||||
private var dtSum = 0L
|
||||
private var dtMin = Int.MAX_VALUE
|
||||
private var dtMax = 0
|
||||
private var lastReportTime = System.currentTimeMillis()
|
||||
|
||||
data class Anomaly(val type: String, val detail: String)
|
||||
|
||||
/**
|
||||
* 输入一帧,返回异常列表(为空则正常)
|
||||
*/
|
||||
fun feed(frame: ImuFrame): List<Anomaly> {
|
||||
val anomalies = mutableListOf<Anomaly>()
|
||||
frameCount++
|
||||
|
||||
// seq 跳变检测
|
||||
if (lastSeqId >= 0) {
|
||||
val expected = lastSeqId + 1
|
||||
if (frame.seqId != expected) {
|
||||
val dropped = frame.seqId - expected
|
||||
dropCount += if (dropped > 0) dropped else 1
|
||||
anomalies.add(Anomaly(
|
||||
"seq_jump",
|
||||
"expected=$expected got=${frame.seqId} dropped=$dropped"
|
||||
))
|
||||
}
|
||||
}
|
||||
lastSeqId = frame.seqId
|
||||
|
||||
// 帧间隔统计
|
||||
if (frame.dtUs > 0) {
|
||||
dtSum += frame.dtUs
|
||||
if (frame.dtUs < dtMin) dtMin = frame.dtUs
|
||||
if (frame.dtUs > dtMax) dtMax = frame.dtUs
|
||||
|
||||
// 帧间隔异常(>50ms)
|
||||
if (frame.dtUs > 50000) {
|
||||
anomalies.add(Anomaly(
|
||||
"dt_high",
|
||||
"dt=${frame.dtUs}us (>${50000}us threshold)"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return anomalies
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取周期统计摘要(调用后重置计数器)
|
||||
*/
|
||||
fun getSummaryAndReset(): String {
|
||||
val avgDt = if (frameCount > 0) dtSum / frameCount else 0
|
||||
val summary = "frames=$frameCount drops=$dropCount dt_avg=${avgDt}us dt_min=${dtMin}us dt_max=${dtMax}us"
|
||||
frameCount = 0
|
||||
dropCount = 0
|
||||
dtSum = 0
|
||||
dtMin = Int.MAX_VALUE
|
||||
dtMax = 0
|
||||
lastReportTime = System.currentTimeMillis()
|
||||
return summary
|
||||
}
|
||||
|
||||
fun shouldReport(): Boolean {
|
||||
return System.currentTimeMillis() - lastReportTime >= 10_000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.kiwii.controlpanel.util
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONTokener
|
||||
|
||||
object JsonFormatter {
|
||||
|
||||
fun format(raw: String): String {
|
||||
if (raw.isBlank()) return ""
|
||||
return try {
|
||||
when (val token = JSONTokener(raw).nextValue()) {
|
||||
is JSONObject -> token.toString(2)
|
||||
is JSONArray -> token.toString(2)
|
||||
else -> raw
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
raw
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#D9F5E5" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#31A66A" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFBED" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#D99000" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F3F8FF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#2F80ED" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#C9D1D9" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F3F8FF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#2F80ED" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFBED" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#D99000" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F4F6F8" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<stroke
|
||||
android:width="1.5dp"
|
||||
android:color="#C9D1D9" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#D5DDE5" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#E8F1FF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#2F80ED" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#E8F8EF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#31A66A" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<stroke
|
||||
android:width="1.5dp"
|
||||
android:color="#C9D1D9" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/nav_host_fragment"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:defaultNavHost="true"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navGraph="@navigation/nav_graph" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/bg_rhs_card"
|
||||
android:padding="8dp">
|
||||
|
||||
<!-- Left: State + Log -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="45"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginEnd="6dp">
|
||||
|
||||
<!-- State Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_rhs_status_inner"
|
||||
android:padding="8dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_state_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:text="State"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_title" />
|
||||
</RelativeLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_state_stats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:overScrollMode="never" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_state_updated_at"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="9sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="@color/rhs_timestamp"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Session Log -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_rhs_status_inner"
|
||||
android:padding="6dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:minHeight="0dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:text="Session Log"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_title" />
|
||||
</RelativeLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_log_entries"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:overScrollMode="never" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: Controls -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="55"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_rhs_control_inner"
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Controls"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_title"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/ops_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_rhs_card"
|
||||
android:padding="8dp">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,147 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/bg_rhs_card"
|
||||
android:padding="8dp">
|
||||
|
||||
<!-- Left: Status + Log -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="45"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginEnd="6dp">
|
||||
|
||||
<!-- Status Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_rhs_status_inner"
|
||||
android:padding="8dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:text="RuntimeHost Status"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_badge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="24dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="@drawable/bg_rhs_badge_bound"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:text="BOUND"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_badge_text" />
|
||||
</RelativeLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_stats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:overScrollMode="never" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_updated_at"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="9sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="@color/rhs_timestamp"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Session Log -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_rhs_status_inner"
|
||||
android:padding="6dp"
|
||||
android:layout_marginTop="6dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:text="Session Log"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_log_toggle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="24dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="@drawable/bg_rhs_log_toggle"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:text="Stop"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_tx_blue" />
|
||||
</RelativeLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:overScrollMode="never" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: Controls -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="55"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_rhs_control_inner"
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Controls"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_title"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:overScrollMode="never" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="4dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="0dp"
|
||||
app:cardBackgroundColor="#FFFFFF"
|
||||
app:strokeColor="#C9D1D9"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/view_state_indicator"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_component_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/rhs_subtitle"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/control_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:layout_margin="3dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?android:attr/selectableItemBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_btn_title"
|
||||
android:gravity="center" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="9sp"
|
||||
android:textColor="@color/rhs_btn_subtitle"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/tv_log_line"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="10sp"
|
||||
android:paddingHorizontal="6dp"
|
||||
android:paddingVertical="2dp" />
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_rhs_stat_box"
|
||||
android:padding="8dp"
|
||||
android:layout_margin="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_stat_label"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_value"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/rhs_stat_value"
|
||||
android:layout_marginTop="2dp"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_detail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="8sp"
|
||||
android:textColor="@color/rhs_badge_green"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_record"
|
||||
android:title="Record"
|
||||
app:showAsAction="always" />
|
||||
|
||||
</menu>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/nav_graph"
|
||||
app:startDestination="@id/homeFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/homeFragment"
|
||||
android:name="com.kiwii.controlpanel.ui.home.HomeFragment"
|
||||
android:label="Control Panel"
|
||||
tools:layout="@layout/fragment_home">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_home_to_detail"
|
||||
app:destination="@id/componentDetailFragment" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/componentDetailFragment"
|
||||
android:name="com.kiwii.controlpanel.ui.detail.ComponentDetailFragment"
|
||||
android:label="Component Detail"
|
||||
tools:layout="@layout/fragment_component_detail">
|
||||
|
||||
<argument
|
||||
android:name="componentType"
|
||||
app:argType="string"
|
||||
android:defaultValue="RUNTIME_HOST_STATUS" />
|
||||
</fragment>
|
||||
</navigation>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="state_active">#4CAF50</color>
|
||||
<color name="state_grey">#9E9E9E</color>
|
||||
<color name="result_success">#2E7D32</color>
|
||||
<color name="result_error">#C62828</color>
|
||||
<color name="result_not_bound">#F9A825</color>
|
||||
|
||||
<!-- RuntimeHostStatus detail page -->
|
||||
<color name="rhs_tx_blue">#2F80ED</color>
|
||||
<color name="rhs_rx_green">#31A66A</color>
|
||||
<color name="rhs_sys_purple">#7B8794</color>
|
||||
<color name="rhs_badge_bg">#D9F5E5</color>
|
||||
<color name="rhs_badge_green">#31A66A</color>
|
||||
<color name="rhs_badge_text">#27313B</color>
|
||||
<color name="rhs_title">#17202A</color>
|
||||
<color name="rhs_subtitle">#52616F</color>
|
||||
<color name="rhs_stat_label">#52616F</color>
|
||||
<color name="rhs_stat_value">#17202A</color>
|
||||
<color name="rhs_btn_title">#17202A</color>
|
||||
<color name="rhs_btn_subtitle">#52616F</color>
|
||||
<color name="rhs_warmstart_title">#D99000</color>
|
||||
<color name="rhs_warmstart_subtitle">#D99000</color>
|
||||
<color name="rhs_timestamp">#7B8794</color>
|
||||
<color name="rhs_success">#31A66A</color>
|
||||
<color name="rhs_warning">#D99000</color>
|
||||
<color name="rhs_info">#2F80ED</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Kiwii Control Panel</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.KiwiiControlPanel" parent="Theme.Material3.DayNight">
|
||||
<item name="colorPrimary">#1976D2</item>
|
||||
<item name="colorPrimaryVariant">#1565C0</item>
|
||||
<item name="colorOnPrimary">@android:color/white</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path name="logs" path="logs/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.kiwii.controlpanel
|
||||
|
||||
import com.kiwii.controlpanel.data.BridgeRepository
|
||||
import com.kiwii.controlpanel.model.OperationResult
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class BridgeRepositoryTest {
|
||||
|
||||
private val repo = BridgeRepository()
|
||||
|
||||
@Test
|
||||
fun `initially not bound`() {
|
||||
assertFalse(repo.isBound())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ping when not bound returns error json`() {
|
||||
val result = repo.ping()
|
||||
assertTrue(result.contains("NOT_BOUND"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getRuntimeStateJson when not bound returns error`() {
|
||||
val result = repo.getRuntimeStateJson()
|
||||
assertTrue(result.contains("NOT_BOUND"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `callAsync wraps successful result`() = runTest {
|
||||
val result = repo.callAsync { "hello" }
|
||||
assertTrue(result is OperationResult.Success)
|
||||
assertEquals("hello", (result as OperationResult.Success).data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `callAsync wraps exception`() = runTest {
|
||||
val result = repo.callAsync { throw RuntimeException("test error") }
|
||||
assertTrue(result is OperationResult.Error)
|
||||
assertEquals("test error", (result as OperationResult.Error).exception.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `callAsync returns NoData for null`() = runTest {
|
||||
val result = repo.callAsync<String?> { null }
|
||||
assertTrue(result is OperationResult.NoData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `callAsync returns NoData for blank string`() = runTest {
|
||||
val result = repo.callAsync { " " }
|
||||
assertTrue(result is OperationResult.NoData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.kiwii.controlpanel
|
||||
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ComponentTypeTest {
|
||||
|
||||
@Test
|
||||
fun `enum has 8 components`() {
|
||||
assertEquals(8, ComponentType.entries.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runtime host status does not require bound`() {
|
||||
assertFalse(ComponentType.RUNTIME_HOST_STATUS.requiresBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session log does not require bound`() {
|
||||
assertFalse(ComponentType.SESSION_LOG.requiresBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `camera requires bound`() {
|
||||
assertTrue(ComponentType.CAMERA.requiresBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `motor requires bound`() {
|
||||
assertTrue(ComponentType.MOTOR.requiresBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all bound-required components`() {
|
||||
val boundRequired = ComponentType.entries.filter { it.requiresBound }
|
||||
assertEquals(6, boundRequired.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `display names are non-empty`() {
|
||||
ComponentType.entries.forEach {
|
||||
assertTrue(it.displayName.isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.kiwii.controlpanel
|
||||
|
||||
import com.kiwii.controlpanel.util.ImuParser
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ImuParserTest {
|
||||
|
||||
@Test
|
||||
fun `parse valid float9 returns correct ImuFrame`() {
|
||||
val data = floatArrayOf(1f, 100f, 0.707f, 0f, 0f, 0.707f, 0.1f, -9.8f, 0.2f)
|
||||
val frame = ImuParser.parse(data)
|
||||
|
||||
assertNotNull(frame)
|
||||
assertEquals(1, frame!!.seqId)
|
||||
assertEquals(100, frame.dtUs)
|
||||
assertEquals(0.707f, frame.qw, 0.001f)
|
||||
assertEquals(0f, frame.qx, 0.001f)
|
||||
assertEquals(0f, frame.qy, 0.001f)
|
||||
assertEquals(0.707f, frame.qz, 0.001f)
|
||||
assertEquals(0.1f, frame.lax, 0.001f)
|
||||
assertEquals(-9.8f, frame.lay, 0.001f)
|
||||
assertEquals(0.2f, frame.laz, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse short array returns null`() {
|
||||
val data = floatArrayOf(1f, 2f, 3f)
|
||||
assertNull(ImuParser.parse(data))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse empty array returns null`() {
|
||||
assertNull(ImuParser.parse(floatArrayOf()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseQueue with multiple frames`() {
|
||||
val queue = arrayOf(
|
||||
floatArrayOf(1f, 100f, 0.5f, 0.5f, 0.5f, 0.5f, 0f, 0f, 0f),
|
||||
floatArrayOf(2f, 200f, 1f, 0f, 0f, 0f, 1f, 1f, 1f)
|
||||
)
|
||||
val frames = ImuParser.parseQueue(queue)
|
||||
assertEquals(2, frames.size)
|
||||
assertEquals(1, frames[0].seqId)
|
||||
assertEquals(2, frames[1].seqId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseQueue filters invalid frames`() {
|
||||
val queue = arrayOf(
|
||||
floatArrayOf(1f, 100f, 0.5f, 0.5f, 0.5f, 0.5f, 0f, 0f, 0f),
|
||||
floatArrayOf(1f, 2f) // too short
|
||||
)
|
||||
val frames = ImuParser.parseQueue(queue)
|
||||
assertEquals(1, frames.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatFrame produces readable output`() {
|
||||
val data = floatArrayOf(42f, 1000f, 1f, 0f, 0f, 0f, 0f, -9.81f, 0f)
|
||||
val frame = ImuParser.parse(data)!!
|
||||
val formatted = ImuParser.formatFrame(frame)
|
||||
assertTrue(formatted.contains("seq=42"))
|
||||
assertTrue(formatted.contains("dt=1000us"))
|
||||
assertTrue(formatted.contains("q=(1.000,0.000,0.000,0.000)"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.kiwii.controlpanel
|
||||
|
||||
import com.kiwii.controlpanel.util.JsonFormatter
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class JsonFormatterTest {
|
||||
|
||||
@Test
|
||||
fun `format invalid json returns raw`() {
|
||||
val raw = "not json at all"
|
||||
assertEquals(raw, JsonFormatter.format(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `format blank string returns empty`() {
|
||||
assertEquals("", JsonFormatter.format(""))
|
||||
assertEquals("", JsonFormatter.format(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `format non-blank non-json returns raw`() {
|
||||
val raw = "hello world 123"
|
||||
assertEquals(raw, JsonFormatter.format(raw))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.kiwii.controlpanel
|
||||
|
||||
import com.kiwii.controlpanel.logging.LogEntry
|
||||
import com.kiwii.controlpanel.model.ComponentType
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class LogEntryTest {
|
||||
|
||||
@Test
|
||||
fun `toLogLine includes all fields`() {
|
||||
val entry = LogEntry(
|
||||
timestamp = 1000L,
|
||||
component = ComponentType.CAMERA,
|
||||
action = "getCameraStateJson",
|
||||
params = "none",
|
||||
result = "{\"ok\":true}",
|
||||
isStateChange = false
|
||||
)
|
||||
val line = entry.toLogLine()
|
||||
assertTrue(line.contains("1000"))
|
||||
assertTrue(line.contains("Camera"))
|
||||
assertTrue(line.contains("getCameraStateJson"))
|
||||
assertTrue(line.contains("none"))
|
||||
assertTrue(line.contains("{\"ok\":true}"))
|
||||
assertTrue(line.contains("false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toLogLine handles null component as GLOBAL`() {
|
||||
val entry = LogEntry(
|
||||
timestamp = 2000L,
|
||||
component = null,
|
||||
action = "connection",
|
||||
params = null,
|
||||
result = "BOUND",
|
||||
isStateChange = true
|
||||
)
|
||||
val line = entry.toLogLine()
|
||||
assertTrue(line.contains("GLOBAL"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toLogLine handles null params and result`() {
|
||||
val entry = LogEntry(
|
||||
timestamp = 3000L,
|
||||
component = ComponentType.MOTOR,
|
||||
action = "test",
|
||||
params = null,
|
||||
result = null,
|
||||
isStateChange = false
|
||||
)
|
||||
val line = entry.toLogLine()
|
||||
assertNotNull(line)
|
||||
assertTrue(line.contains("Motor"))
|
||||
}
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<svg width="1600" height="960" viewBox="0 0 1600 960" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="220" y1="96" x2="1364" y2="902" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F4F6F8"/>
|
||||
<stop offset="1" stop-color="#F4F6F8"/>
|
||||
</linearGradient>
|
||||
<filter id="cardShadow" x="0" y="0" width="2000" height="1400" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feOffset dy="20"/>
|
||||
<feGaussianBlur stdDeviation="20"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.133333 0 0 0 0 0.180392 0 0 0 0 0.258824 0 0 0 0.1 0"/>
|
||||
<feBlend in2="SourceGraphic" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="statusGlow" x1="136" y1="156" x2="696" y2="428" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#FFFFFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="controlGlow" x1="844" y1="132" x2="1440" y2="836" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#FFFFFF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="1600" height="960" fill="url(#bg)"/>
|
||||
|
||||
<g filter="url(#cardShadow)">
|
||||
<rect x="20" y="20" width="1560" height="920" rx="10" fill="#F4F6F8"/>
|
||||
</g>
|
||||
|
||||
<g filter="url(#cardShadow)">
|
||||
<rect x="36" y="36" width="724" height="302" rx="10" fill="url(#statusGlow)"/>
|
||||
<rect x="36.75" y="36.75" width="722.5" height="300.5" rx="10" stroke="#C9D1D9" stroke-width="2"/>
|
||||
|
||||
<text x="72" y="120" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">RuntimeHost Status</text>
|
||||
<text x="72" y="148" fill="#52616F" font-family="Arial, sans-serif" font-size="14" font-weight="500">Service lifecycle, bind state, runtime snapshot</text>
|
||||
|
||||
<rect x="584" y="94" width="142" height="40" rx="10" fill="#D9F5E5" stroke="#31A66A"/>
|
||||
<circle cx="610" cy="114" r="6" fill="#31A66A"/>
|
||||
<text x="625" y="118" fill="#27313B" font-family="Arial, sans-serif" font-size="14" font-weight="700">BOUND</text>
|
||||
|
||||
<rect x="72" y="184" width="216" height="118" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="72.75" y="184.75" width="214.5" height="116.5" rx="10" stroke="#D5DDE5" stroke-width="1.5"/>
|
||||
<text x="98" y="217" fill="#52616F" font-family="Arial, sans-serif" font-size="14" font-weight="600">Connection</text>
|
||||
<text x="98" y="263" fill="#17202A" font-family="Arial, sans-serif" font-size="38" font-weight="700">Active</text>
|
||||
<text x="98" y="286" fill="#31A66A" font-family="Arial, sans-serif" font-size="14" font-weight="700">USB Ready</text>
|
||||
|
||||
<rect x="304" y="184" width="216" height="118" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="304.75" y="184.75" width="214.5" height="116.5" rx="10" stroke="#D5DDE5" stroke-width="1.5"/>
|
||||
<text x="330" y="217" fill="#52616F" font-family="Arial, sans-serif" font-size="14" font-weight="600">Runtime State</text>
|
||||
<text x="330" y="263" fill="#17202A" font-family="Arial, sans-serif" font-size="38" font-weight="700">Warm</text>
|
||||
<text x="330" y="286" fill="#D99000" font-family="Arial, sans-serif" font-size="14" font-weight="700">Last ping 00:01.2</text>
|
||||
|
||||
<rect x="536" y="184" width="190" height="118" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="536.75" y="184.75" width="188.5" height="116.5" rx="10" stroke="#D5DDE5" stroke-width="1.5"/>
|
||||
<text x="562" y="217" fill="#52616F" font-family="Arial, sans-serif" font-size="14" font-weight="600">Health</text>
|
||||
<text x="562" y="263" fill="#17202A" font-family="Arial, sans-serif" font-size="38" font-weight="700">98%</text>
|
||||
<text x="562" y="286" fill="#2F80ED" font-family="Arial, sans-serif" font-size="14" font-weight="700">No crash signal</text>
|
||||
|
||||
<text x="72" y="322" fill="#7B8794" font-family="Courier, monospace" font-size="13">Updated 14:32:08 [SDK]</text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#cardShadow)">
|
||||
<rect x="36" y="346" width="724" height="558" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="36.75" y="346.75" width="722.5" height="556.5" rx="10" stroke="#C9D1D9" stroke-width="2"/>
|
||||
|
||||
<text x="72" y="402" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Session Log</text>
|
||||
<text x="72" y="428" fill="#52616F" font-family="Arial, sans-serif" font-size="14" font-weight="500">Tx blue, Rx green, system purple</text>
|
||||
|
||||
<rect x="574" y="378" width="154" height="42" rx="10" fill="#E8F1FF"/>
|
||||
<rect x="574.75" y="378.75" width="152.5" height="40.5" rx="10" stroke="#2F80ED" stroke-width="1.5"/>
|
||||
<circle cx="598" cy="399" r="7" fill="#31A66A"/>
|
||||
<text x="656" y="404" text-anchor="middle" fill="#2F80ED" font-family="Arial, sans-serif" font-size="15" font-weight="700">Start / Stop</text>
|
||||
|
||||
<rect x="72" y="452" width="640" height="416" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="72.75" y="452.75" width="638.5" height="414.5" rx="10" stroke="#D5DDE5" stroke-width="1.5"/>
|
||||
|
||||
<text x="104" y="492" fill="#2F80ED" font-family="Courier, monospace" font-size="15">14:32:06.212 [TX] bind request sent</text>
|
||||
<text x="104" y="518" fill="#31A66A" font-family="Courier, monospace" font-size="15">14:32:06.438 [RX] ping => "pong"</text>
|
||||
<text x="104" y="544" fill="#7B8794" font-family="Courier, monospace" font-size="15">14:32:07.042 [SYS] state poll updated</text>
|
||||
<text x="104" y="570" fill="#31A66A" font-family="Courier, monospace" font-size="15">14:32:08.115 [RX] getHealthStateJson => {"ok":true}</text>
|
||||
<text x="104" y="596" fill="#2F80ED" font-family="Courier, monospace" font-size="15">14:32:08.640 [TX] request warm start</text>
|
||||
<text x="104" y="622" fill="#7B8794" font-family="Courier, monospace" font-size="15">14:32:09.021 [SYS] log stream running</text>
|
||||
<text x="104" y="648" fill="#31A66A" font-family="Courier, monospace" font-size="15">14:32:09.302 [RX] runtime lifecycle => WARM</text>
|
||||
<text x="104" y="674" fill="#2F80ED" font-family="Courier, monospace" font-size="15">14:32:09.618 [TX] getDebugSnapshotJson</text>
|
||||
<text x="104" y="700" fill="#31A66A" font-family="Courier, monospace" font-size="15">14:32:09.774 [RX] debug snapshot => 1.4 KB</text>
|
||||
<text x="104" y="726" fill="#7B8794" font-family="Courier, monospace" font-size="15">14:32:10.001 [SYS] snapshot cached</text>
|
||||
|
||||
<rect x="684" y="486" width="12" height="330" rx="6" fill="#E5E9EE"/>
|
||||
<rect x="685.5" y="566" width="9" height="106" rx="4.5" fill="#A4AFBA"/>
|
||||
<circle cx="690" cy="474" r="12" fill="#EEF1F4"/>
|
||||
<path d="M685.5 477L690 471L694.5 477" stroke="#7B8794" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="690" cy="832" r="12" fill="#EEF1F4"/>
|
||||
<path d="M685.5 829L690 835L694.5 829" stroke="#7B8794" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<g filter="url(#cardShadow)">
|
||||
<rect x="772" y="36" width="792" height="868" rx="10" fill="url(#controlGlow)"/>
|
||||
<rect x="772.75" y="36.75" width="790.5" height="866.5" rx="10" stroke="#C9D1D9" stroke-width="2"/>
|
||||
|
||||
<text x="832" y="120" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Controls</text>
|
||||
<text x="832" y="148" fill="#52616F" font-family="Arial, sans-serif" font-size="14" font-weight="500">Rounded grid buttons fill control card</text>
|
||||
|
||||
<g>
|
||||
<rect x="832" y="214" width="208" height="136" rx="10" fill="#F3F8FF"/>
|
||||
<rect x="832.75" y="214.75" width="206.5" height="134.5" rx="10" stroke="#2F80ED" stroke-width="1.5"/>
|
||||
<text x="936" y="276" text-anchor="middle" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Bind</text>
|
||||
<text x="936" y="306" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">Start bridge</text>
|
||||
<text x="936" y="326" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">session</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1064" y="214" width="208" height="136" rx="10" fill="#F3F8FF"/>
|
||||
<rect x="1064.75" y="214.75" width="206.5" height="134.5" rx="10" stroke="#2F80ED" stroke-width="1.5"/>
|
||||
<text x="1168" y="276" text-anchor="middle" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Unbind</text>
|
||||
<text x="1168" y="306" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">Close service</text>
|
||||
<text x="1168" y="326" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">link</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1296" y="214" width="208" height="136" rx="10" fill="#F3F8FF"/>
|
||||
<rect x="1296.75" y="214.75" width="206.5" height="134.5" rx="10" stroke="#2F80ED" stroke-width="1.5"/>
|
||||
<text x="1400" y="276" text-anchor="middle" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Ping</text>
|
||||
<text x="1400" y="306" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">Check round</text>
|
||||
<text x="1400" y="326" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">trip</text>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<rect x="832" y="370" width="208" height="136" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="832.75" y="370.75" width="206.5" height="134.5" rx="10" stroke="#D5DDE5" stroke-width="1.5"/>
|
||||
<text x="936" y="432" text-anchor="middle" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Runtime State</text>
|
||||
<text x="936" y="462" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">getRuntime</text>
|
||||
<text x="936" y="482" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">StateJson</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1064" y="370" width="208" height="136" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="1064.75" y="370.75" width="206.5" height="134.5" rx="10" stroke="#D5DDE5" stroke-width="1.5"/>
|
||||
<text x="1168" y="432" text-anchor="middle" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Health</text>
|
||||
<text x="1168" y="462" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">getHealth</text>
|
||||
<text x="1168" y="482" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">StateJson</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1296" y="370" width="208" height="136" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="1296.75" y="370.75" width="206.5" height="134.5" rx="10" stroke="#D5DDE5" stroke-width="1.5"/>
|
||||
<text x="1400" y="432" text-anchor="middle" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Debug</text>
|
||||
<text x="1400" y="462" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">getDebug</text>
|
||||
<text x="1400" y="482" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">SnapshotJson</text>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<rect x="832" y="526" width="208" height="136" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="832.75" y="526.75" width="206.5" height="134.5" rx="10" stroke="#D5DDE5" stroke-width="1.5"/>
|
||||
<text x="936" y="588" text-anchor="middle" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Lifecycle</text>
|
||||
<text x="936" y="618" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">getRuntimeLifecycle</text>
|
||||
<text x="936" y="638" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">StateJson</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1064" y="526" width="440" height="136" rx="10" fill="#FFFBED"/>
|
||||
<rect x="1064.75" y="526.75" width="438.5" height="134.5" rx="10" stroke="#D99000" stroke-width="1.5"/>
|
||||
<text x="1284" y="588" text-anchor="middle" fill="#17202A" font-family="Arial, sans-serif" font-size="22" font-weight="700">Warm Start</text>
|
||||
<text x="1284" y="618" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">requestRuntime</text>
|
||||
<text x="1284" y="638" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">WarmStart</text>
|
||||
</g>
|
||||
|
||||
<g opacity="0.8">
|
||||
<rect x="832" y="682" width="672" height="140" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="832.75" y="682.75" width="670.5" height="138.5" rx="10" stroke="#C9D1D9" stroke-width="1.5"/>
|
||||
<text x="1168" y="744" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="22" font-weight="700">Room for future actions</text>
|
||||
<text x="1168" y="774" text-anchor="middle" fill="#52616F" font-family="Arial, sans-serif" font-size="14">without shrinking button grid</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
@@ -0,0 +1,170 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="3840" height="2160" viewBox="0 0 3840 2160">
|
||||
<defs>
|
||||
<style>
|
||||
.bg { fill: #f4f6f8; }
|
||||
.panel { fill: #ffffff; stroke: #c9d1d9; stroke-width: 3; }
|
||||
.panel-disabled { fill: #eef1f4; stroke: #c9d1d9; stroke-width: 3; }
|
||||
.panel-active { fill: #ffffff; stroke: #2f80ed; stroke-width: 4; }
|
||||
.preview { fill: #101820; stroke: #2f80ed; stroke-width: 3; }
|
||||
.title { font-family: Arial; font-size: 48px; font-weight: 700; fill: #17202a; }
|
||||
.subtitle { font-family: Arial; font-size: 28px; fill: #52616f; }
|
||||
.section { font-family: Arial; font-size: 32px; font-weight: 700; fill: #17202a; }
|
||||
.label { font-family: Arial; font-size: 24px; fill: #27313b; }
|
||||
.muted { font-family: Arial; font-size: 23px; fill: #7b8794; }
|
||||
.disabled-text { font-family: Arial; font-size: 24px; fill: #8a96a3; }
|
||||
.mono { font-family: Courier; font-size: 24px; fill: #39434d; }
|
||||
.mono-light { font-family: Courier; font-size: 30px; fill: #c9d9ea; }
|
||||
.chip-ok { fill: #d9f5e5; stroke: #31a66a; stroke-width: 2; }
|
||||
.chip-warn { fill: #fff4d7; stroke: #d99000; stroke-width: 2; }
|
||||
.chip-off { fill: #e5e9ee; stroke: #a4afba; stroke-width: 2; }
|
||||
.button { fill: #ffffff; stroke: #aab4bf; stroke-width: 2; }
|
||||
.button-primary { fill: #e8f1ff; stroke: #2f80ed; stroke-width: 2; }
|
||||
.button-disabled { fill: #e3e7eb; stroke: #c2cbd3; stroke-width: 2; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect class="bg" x="0" y="0" width="3840" height="2160"/>
|
||||
|
||||
<text class="title" x="80" y="90">kiwii-sdk-control-panel</text>
|
||||
<text class="subtitle" x="80" y="136">AAR SDK validation control panel. Components stay visible; unavailable surfaces stay gray until detected.</text>
|
||||
|
||||
<rect class="panel-active" x="80" y="190" width="3680" height="230" rx="10"/>
|
||||
<text class="section" x="130" y="246">Global RuntimeHost Status</text>
|
||||
<text class="muted" x="130" y="288">RuntimeHost binding controls SDK access. Component availability remains detected per device.</text>
|
||||
|
||||
<rect class="chip-warn" x="130" y="320" width="230" height="54" rx="10"/>
|
||||
<text class="label" x="170" y="355">Bound: false</text>
|
||||
<rect class="chip-off" x="390" y="320" width="280" height="54" rx="10"/>
|
||||
<text class="label" x="428" y="355">Ping: NOT_BOUND</text>
|
||||
<text class="muted" x="720" y="355">Last API: none</text>
|
||||
<text class="muted" x="980" y="355">Last error: waiting for RuntimeHost bind</text>
|
||||
|
||||
<rect class="button-primary" x="2980" y="260" width="180" height="64" rx="10"/>
|
||||
<text class="label" x="3035" y="301">Bind</text>
|
||||
<rect class="button" x="3190" y="260" width="200" height="64" rx="10"/>
|
||||
<text class="label" x="3240" y="301">Unbind</text>
|
||||
<rect class="button" x="3420" y="260" width="160" height="64" rx="10"/>
|
||||
<text class="label" x="3470" y="301">Ping</text>
|
||||
|
||||
<rect class="panel-active" x="80" y="470" width="875" height="300" rx="10"/>
|
||||
<text class="section" x="130" y="528">RuntimeHostStatus</text>
|
||||
<rect class="chip-ok" x="130" y="552" width="170" height="46" rx="10"/>
|
||||
<text class="label" x="172" y="582">Active</text>
|
||||
<text class="muted" x="130" y="642">Always interactive. Owns bind, runtime, health, and lifecycle checks.</text>
|
||||
<rect class="button-primary" x="130" y="682" width="180" height="54" rx="10"/>
|
||||
<text class="label" x="168" y="717">Bind Host</text>
|
||||
<rect class="button" x="330" y="682" width="180" height="54" rx="10"/>
|
||||
<text class="label" x="378" y="717">Runtime</text>
|
||||
<rect class="button" x="530" y="682" width="150" height="54" rx="10"/>
|
||||
<text class="label" x="573" y="717">Health</text>
|
||||
<rect class="button" x="700" y="682" width="180" height="54" rx="10"/>
|
||||
<text class="label" x="742" y="717">Lifecycle</text>
|
||||
|
||||
<rect class="panel-disabled" x="995" y="470" width="875" height="300" rx="10"/>
|
||||
<text class="section" x="1045" y="528">Camera</text>
|
||||
<rect class="chip-off" x="1045" y="552" width="270" height="46" rx="10"/>
|
||||
<text class="disabled-text" x="1085" y="582">Gray: not detected</text>
|
||||
<text class="disabled-text" x="1045" y="642">Requires RuntimeHost binding and camera state availability.</text>
|
||||
<rect class="button" x="1045" y="682" width="180" height="54" rx="10"/>
|
||||
<text class="label" x="1100" y="717">Detect</text>
|
||||
<rect class="button-disabled" x="1245" y="682" width="160" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="1293" y="717">State</text>
|
||||
<rect class="button-disabled" x="1425" y="682" width="280" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="1460" y="717">Full Camera View</text>
|
||||
|
||||
<rect class="panel-disabled" x="1910" y="470" width="875" height="300" rx="10"/>
|
||||
<text class="section" x="1960" y="528">Left Handle</text>
|
||||
<rect class="chip-off" x="1960" y="552" width="270" height="46" rx="10"/>
|
||||
<text class="disabled-text" x="2000" y="582">Gray: not detected</text>
|
||||
<text class="disabled-text" x="1960" y="642">Requires left handle data before IMU and haptics are enabled.</text>
|
||||
<rect class="button" x="1960" y="682" width="180" height="54" rx="10"/>
|
||||
<text class="label" x="2015" y="717">Detect</text>
|
||||
<rect class="button-disabled" x="2160" y="682" width="150" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="2208" y="717">IMU</text>
|
||||
<rect class="button-disabled" x="2330" y="682" width="180" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="2372" y="717">Haptics</text>
|
||||
|
||||
<rect class="panel-disabled" x="2825" y="470" width="935" height="300" rx="10"/>
|
||||
<text class="section" x="2875" y="528">Right Handle</text>
|
||||
<rect class="chip-off" x="2875" y="552" width="270" height="46" rx="10"/>
|
||||
<text class="disabled-text" x="2915" y="582">Gray: not detected</text>
|
||||
<text class="disabled-text" x="2875" y="642">Requires right handle data before IMU and haptics are enabled.</text>
|
||||
<rect class="button" x="2875" y="682" width="180" height="54" rx="10"/>
|
||||
<text class="label" x="2930" y="717">Detect</text>
|
||||
<rect class="button-disabled" x="3075" y="682" width="150" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="3123" y="717">IMU</text>
|
||||
<rect class="button-disabled" x="3245" y="682" width="180" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="3287" y="717">Haptics</text>
|
||||
|
||||
<rect class="panel-disabled" x="80" y="820" width="875" height="300" rx="10"/>
|
||||
<text class="section" x="130" y="878">Balance Board</text>
|
||||
<rect class="chip-off" x="130" y="902" width="270" height="46" rx="10"/>
|
||||
<text class="disabled-text" x="170" y="932">Gray: not detected</text>
|
||||
<text class="disabled-text" x="130" y="992">Requires balance board data before telemetry views are enabled.</text>
|
||||
<rect class="button" x="130" y="1032" width="180" height="54" rx="10"/>
|
||||
<text class="label" x="185" y="1067">Detect</text>
|
||||
<rect class="button-disabled" x="330" y="1032" width="170" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="383" y="1067">State</text>
|
||||
<rect class="button-disabled" x="520" y="1032" width="200" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="570" y="1067">Balance</text>
|
||||
|
||||
<rect class="panel-disabled" x="995" y="820" width="875" height="300" rx="10"/>
|
||||
<text class="section" x="1045" y="878">Motor</text>
|
||||
<rect class="chip-off" x="1045" y="902" width="270" height="46" rx="10"/>
|
||||
<text class="disabled-text" x="1085" y="932">Gray: not detected</text>
|
||||
<text class="disabled-text" x="1045" y="992">Requires motor state and motor control state.</text>
|
||||
<rect class="button" x="1045" y="1032" width="180" height="54" rx="10"/>
|
||||
<text class="label" x="1100" y="1067">Detect</text>
|
||||
<rect class="button-disabled" x="1245" y="1032" width="180" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="1290" y="1067">Set Kg</text>
|
||||
<rect class="button-disabled" x="1445" y="1032" width="180" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="1495" y="1067">Mode</text>
|
||||
|
||||
<rect class="panel-disabled" x="1910" y="820" width="875" height="300" rx="10"/>
|
||||
<text class="section" x="1960" y="878">Telemetry Safety</text>
|
||||
<rect class="chip-off" x="1960" y="902" width="220" height="46" rx="10"/>
|
||||
<text class="disabled-text" x="1998" y="932">Gray: bound</text>
|
||||
<text class="disabled-text" x="1960" y="992">Available after RuntimeHost is bound.</text>
|
||||
<rect class="button-disabled" x="1960" y="1032" width="210" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="2010" y="1067">Telemetry</text>
|
||||
<rect class="button-disabled" x="2190" y="1032" width="170" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="2240" y="1067">Safety</text>
|
||||
<rect class="button-disabled" x="2380" y="1032" width="220" height="54" rx="10"/>
|
||||
<text class="disabled-text" x="2425" y="1067">Device Cmd</text>
|
||||
|
||||
<rect class="panel" x="2825" y="820" width="935" height="300" rx="10"/>
|
||||
<text class="section" x="2875" y="878">Session Log</text>
|
||||
<rect class="chip-ok" x="2875" y="902" width="170" height="46" rx="10"/>
|
||||
<text class="label" x="2918" y="932">Active</text>
|
||||
<text class="muted" x="2875" y="992">Records SDK calls and skipped actions.</text>
|
||||
<rect x="2875" y="1032" width="760" height="54" rx="10" fill="#ffffff" stroke="#d5dde5"/>
|
||||
<text class="mono" x="2900" y="1067">16:27:05 camera.detect -> skipped</text>
|
||||
|
||||
<rect class="panel" x="80" y="1170" width="2390" height="850" rx="10"/>
|
||||
<text class="section" x="130" y="1230">CameraComponent Detail View</text>
|
||||
<text class="muted" x="130" y="1270">Selected camera detail keeps the component grid visible while showing a full preview and camera actions.</text>
|
||||
<rect class="preview" x="130" y="1310" width="1520" height="580" rx="10"/>
|
||||
<text class="mono-light" x="690" y="1588">Full Camera Preview</text>
|
||||
<text class="mono-light" x="655" y="1636">16:9 frame, full visible image</text>
|
||||
<rect class="button-disabled" x="1710" y="1310" width="320" height="64" rx="10"/>
|
||||
<text class="disabled-text" x="1790" y="1352">Camera State</text>
|
||||
<rect class="button-disabled" x="1710" y="1400" width="320" height="64" rx="10"/>
|
||||
<text class="disabled-text" x="1780" y="1442">Refresh Frame</text>
|
||||
<rect class="button-disabled" x="1710" y="1490" width="320" height="64" rx="10"/>
|
||||
<text class="disabled-text" x="1775" y="1532">Open Fullscreen</text>
|
||||
<text class="mono" x="1710" y="1645">status: unavailable</text>
|
||||
<text class="mono" x="1710" y="1685">reason: Host not bound</text>
|
||||
|
||||
<rect class="panel" x="2530" y="1170" width="1230" height="850" rx="10"/>
|
||||
<text class="section" x="2580" y="1230">Selected Component Result</text>
|
||||
<text class="muted" x="2580" y="1270">Latest call, params, status, result, and error summary.</text>
|
||||
<rect x="2580" y="1310" width="1040" height="210" rx="10" fill="#ffffff" stroke="#d5dde5"/>
|
||||
<text class="mono" x="2610" y="1360">selected=CameraComponent</text>
|
||||
<text class="mono" x="2610" y="1405">call=detect</text>
|
||||
<text class="mono" x="2610" y="1450">status=idle</text>
|
||||
<text class="mono" x="2610" y="1495">result=waiting for RuntimeHost bind</text>
|
||||
<rect x="2580" y="1570" width="1040" height="340" rx="10" fill="#ffffff" stroke="#d5dde5"/>
|
||||
<text class="mono" x="2610" y="1620">16:27:00 bind() -> false</text>
|
||||
<text class="mono" x="2610" y="1665">16:27:05 camera.detect -> skipped</text>
|
||||
<text class="mono" x="2610" y="1710">16:27:08 motor.detect -> skipped</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
Vendored
BIN
Binary file not shown.
+6
@@ -0,0 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld -- "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NonStop* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME"
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
;;
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stracks://gnu.org/s/libc/manual/html_node/Argument-Syntax.html
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "kiwii-sdk-control-panel"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user