4 Commits

Author SHA1 Message Date
pNexus 8a90d29b2a build(android): 初始化控制面板工程
添加 Android Gradle 工程骨架和应用模块,给 SDK 控制面板后续界面与联调提供可运行基础。
2026-06-10 18:07:18 +08:00
pNexus 0ff8e88054 config(git): 忽略 Android 构建产物
补充 Gradle、Kotlin、IDE 与 APK/AAR 产物忽略规则,避免控制面板工程初始化后生成文件污染仓库。
2026-06-10 18:07:05 +08:00
pNexus 6ee80834ee docs(design): 补充控制面板设计稿
补充控制面板设计说明,并细化 RuntimeHost 状态与主界面线框图,便于后续实现对齐交互范围。
2026-06-10 18:06:55 +08:00
pNexus 5cfa0fc591 docs(project): 添加项目文档 2026-06-09 17:32:09 +08:00
82 changed files with 4076 additions and 0 deletions
+18
View File
@@ -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/
+148
View File
@@ -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.
+56
View File
@@ -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"))
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.
+33
View File
@@ -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,67 @@
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 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> = KiwiiRuntimeClientBridge.getLeftHandleIMUQueue()
fun submitLeftStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitLeftStaticEffect(effectId)
fun submitLeftHeStream(streamId: Int, payload: String): String = KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, payload)
fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest()
fun getRightHandleIMUQueue(): Array<FloatArray> = KiwiiRuntimeClientBridge.getRightHandleIMUQueue()
fun submitRightStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitRightStaticEffect(effectId)
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,192 @@
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>()
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
val apis = mapOf(
"getCameraStateJson" to runCatching { repo.getCameraStateJson() }.getOrDefault("EXCEPTION"),
"getLatestMotorStateJson" to runCatching { repo.getLatestMotorStateJson() }.getOrDefault("EXCEPTION"),
"getDebugSnapshotJson(first200)" to runCatching { repo.getDebugSnapshotJson().take(200) }.getOrDefault("EXCEPTION"),
"getHandleStateJson" to runCatching { repo.getHandleStateJson() }.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 {
return try {
// Dongle 是 USB 外设,状态在 getDebugSnapshotJson 的全量数据中
val json = repo.getDebugSnapshotJson()
if (isUnavailable(json)) return ComponentState.GREY
// dongleState 字段存在说明 Dongle USB 已连接并被 RuntimeHost 识别
if (json.contains("\"dongleState\"")) {
ComponentState.ACTIVE
} else {
ComponentState.GREY
}
} catch (_: Exception) {
ComponentState.GREY
}
}
private fun checkMotor(): ComponentState {
return try {
val json = repo.getLatestMotorStateJson()
if (isUnavailable(json)) return ComponentState.GREY
// motor-runtime-state.v1 has top-level "available" field
val obj = JSONObject(json)
if (obj.optBoolean("available", false)) ComponentState.ACTIVE else ComponentState.GREY
} 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.getDebugSnapshotJson()
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,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 DongleOps(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,63 @@
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" }
}
})
layout.addView(createSection("Haptics"))
val effectInput = createParameterInput("Effect ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val staticEffectAction = createButton("submitLeftStaticEffect") {
val id = effectInput.text.toString().toIntOrNull() ?: 0
viewModel.executeOperation(ComponentType.LEFT_HANDLE, "submitLeftStaticEffect", "effectId=$id") {
viewModel.bridgeRepo.submitLeftStaticEffect(id)
}
}
layout.addView(createParameterActionRow(staticEffectAction, effectInput))
val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val 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,63 @@
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" }
}
})
layout.addView(createSection("Haptics"))
val effectInput = createParameterInput("Effect ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val staticEffectAction = createButton("submitRightStaticEffect") {
val id = effectInput.text.toString().toIntOrNull() ?: 0
viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "submitRightStaticEffect", "effectId=$id") {
viewModel.bridgeRepo.submitRightStaticEffect(id)
}
}
layout.addView(createParameterActionRow(staticEffectAction, effectInput))
val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER)
val 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,225 @@
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())
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 = RuntimeHostStatusParser.parse(snapshot.data, viewModel.bridgeRepo.isBound())
.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 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,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>
+20
View File
@@ -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>
+19
View File
@@ -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>
+10
View File
@@ -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" />
+40
View File
@@ -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>
+10
View File
@@ -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>
+31
View File
@@ -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>
+28
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kiwii Control Panel</string>
</resources>
+8
View File
@@ -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>
+4
View File
@@ -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"))
}
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.0.1" apply false
}
+168
View File
@@ -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 =&gt; &quot;pong&quot;</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 =&gt; {&quot;ok&quot;: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 =&gt; 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 =&gt; 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

+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+6
View File
@@ -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
Vendored Executable
+90
View File
@@ -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" "$@"
+17
View File
@@ -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")