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