10 Commits

Author SHA1 Message Date
zhifeige 4238ecd56e Fix dongle state, slot evidence, and motor telemetry panel 2026-06-13 22:39:42 +08:00
zhifeige 0baf371bbd Avoid blocking reads in SDK panel dongle diagnostics 2026-06-13 18:51:16 +08:00
zhifeige 6500e0108f Improve SDK panel dongle state diagnostics 2026-06-13 18:00:30 +08:00
zhifeige 80452a7a7d Sync RuntimeHost bridge for handle state contract 2026-06-12 19:07:52 +08:00
zhifeige 7040752c3f Gate motor status on dongle and fresh telemetry 2026-06-10 23:47:49 +08:00
zhifeige a3fc108156 Gate motor status on dongle and fresh telemetry 2026-06-10 23:46:37 +08:00
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
84 changed files with 6242 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,745 @@
package com.kiwii.controlpanel.data
import android.app.Activity
import com.kiwii.bridge.KiwiiRuntimeClientBridge
import com.kiwii.controlpanel.ui.components.MotorTelemetrySnapshotCache
import com.kiwii.controlpanel.model.OperationResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class BridgeRepository {
private companion object {
const val HANDLE_CACHE_MAX_AGE_MS = 5000L
const val HANDLE_REFRESH_MIN_INTERVAL_MS = 1000L
const val HANDLE_REFRESH_STUCK_RESET_MS = 5000L
const val DONGLE_CACHE_MAX_AGE_MS = 3000L
const val DONGLE_REFRESH_MIN_INTERVAL_MS = 1000L
const val DONGLE_REFRESH_STUCK_RESET_MS = 5000L
@Volatile
private var cachedHandleStateJson: String = ""
@Volatile
private var cachedHandleStateAtMs: Long = 0L
@Volatile
private var lastHandleRefreshAttemptMs: Long = 0L
private val handleRefreshInFlight = AtomicBoolean(false)
private val handleRefreshExecutor = Executors.newCachedThreadPool { runnable ->
Thread(runnable, "SDKPanelHandleStateRefresh").apply { isDaemon = true }
}
@Volatile
private var cachedDongleStateJson: String = ""
@Volatile
private var cachedDongleStateAtMs: Long = 0L
@Volatile
private var lastDongleRefreshAttemptMs: Long = 0L
private val dongleRefreshInFlight = AtomicBoolean(false)
private val dongleRefreshExecutor = Executors.newCachedThreadPool { runnable ->
Thread(runnable, "SDKPanelDongleStateRefresh").apply { isDaemon = true }
}
}
fun bind(activity: Activity): Boolean = KiwiiRuntimeClientBridge.bind(activity)
fun unbind() = KiwiiRuntimeClientBridge.unbind()
fun isBound(): Boolean = KiwiiRuntimeClientBridge.isBound()
fun ping(): String = KiwiiRuntimeClientBridge.ping()
fun getRuntimeStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeStateJson()
fun getHealthStateJson(): String = KiwiiRuntimeClientBridge.getHealthStateJson()
fun getDebugSnapshotJson(): String = KiwiiRuntimeClientBridge.getDebugSnapshotJson()
fun getDongleStateJson(): String {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod("getDongleStateJson")
method.invoke(null) as? String ?: getDebugSnapshotJson()
} catch (_: Throwable) {
// Backward-compatible fallback for old unity-bridge.aar builds.
getDebugSnapshotJson()
}
}
fun getRuntimeLifecycleStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeLifecycleStateJson()
fun requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason)
fun getCameraStateJson(): String = KiwiiRuntimeClientBridge.getCameraStateJson()
/**
* Phase4B-v11.2 compile fix: keep the Phase4B camera observation API that CameraOps
* and RuntimeStateSource already depend on. v11 overwrote BridgeRepository while adding
* Dongle detail-state fusion and accidentally dropped this method.
*/
fun getCameraObservationStateJson(): String {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod("getCameraObservationStateJson")
method.invoke(null) as? String ?: getCameraStateJson()
} catch (_: Throwable) {
try {
val out = JSONObject()
val camera = parseJsonOrEmpty(getCameraStateJson())
val health = parseJsonOrEmpty(getHealthStateJson())
val debug = runCatching { parseJsonOrEmpty(getDebugSnapshotJson()) }.getOrDefault(JSONObject())
val latestPose2D = runCatching { getLatestPose2D() }.getOrDefault(FloatArray(0))
val latestPose3D = runCatching { getLatestPose3D() }.getOrDefault(FloatArray(0))
val runtimeStats = runCatching { getLatestRuntimeStats() }.getOrDefault(FloatArray(0))
val statsActive = runtimeStats.any { value -> !value.isNaN() && !value.isInfinite() && value > 0.1f }
val pipeline = health.optJSONObject("realInternalCameraPipeline")
?: camera.optJSONObject("realInternalCameraPipeline")
?: debug.optJSONObject("realInternalCameraPipeline")
?: JSONObject()
val pose2DState = health.optJSONObject("pose2DState")
?: camera.optJSONObject("pose2DState")
?: debug.optJSONObject("pose2DState")
?: JSONObject()
val pose3DState = health.optJSONObject("pose3DState")
?: camera.optJSONObject("pose3DState")
?: debug.optJSONObject("pose3DState")
?: JSONObject()
out.put("contractVersion", "kiwii.sdk-panel.camera-observation-display.v11.2")
out.put("source", "SDK_Panel.camera-observation-compat")
out.put("pipeline", JSONObject()
.put("state", if (statsActive || latestPose2D.isNotEmpty() || latestPose3D.isNotEmpty()) "RUNNING" else "STARTING")
.put("imx415OpenSucceeded", pipeline.optBoolean("imx415OpenSucceeded", health.optBoolean("imx415OpenSucceeded", false)))
.put("rgaEnabled", pipeline.optBoolean("rgaEnabled", health.optBoolean("rgaEnabled", false)))
.put("rknnYoloEnabled", pipeline.optBoolean("rknnYoloEnabled", health.optBoolean("rknnYoloEnabled", false)))
.put("onnxInferenceEnabled", pipeline.optBoolean("onnxInferenceEnabled", health.optBoolean("onnxInferenceEnabled", false)))
.put("videoPose3DEnabled", pipeline.optBoolean("videoPose3DEnabled", health.optBoolean("videoPose3DEnabled", false)))
)
out.put("pose2DLatest", JSONObject()
.put("available", pose2DState.optBoolean("available", latestPose2D.isNotEmpty()))
.put("sequenceId", pose2DState.optLong("pose2DSequenceId", 0L))
.put("arrayLength", latestPose2D.size)
)
out.put("pose3DLatest", JSONObject()
.put("available", pose3DState.optBoolean("available", latestPose3D.isNotEmpty()))
.put("sequenceId", pose3DState.optLong("pose3DSequenceId", 0L))
.put("arrayLength", latestPose3D.size)
.put("pose3DModelFrames", pose3DState.optInt("pose3DModelFrames", pipeline.optInt("pose3DModelFrames", health.optInt("pose3DModelFrames", 27))))
)
out.put("runtimeStatsActive", statsActive)
out.put("rawCameraState", camera)
out.toString()
} catch (t: Throwable) {
JSONObject()
.put("contractVersion", "kiwii.sdk-panel.camera-observation-display.v11.2")
.put("error", t.javaClass.simpleName + ": " + (t.message ?: "unknown"))
.toString()
}
}
}
fun getPerceptionStateJson(): String = KiwiiRuntimeClientBridge.getPerceptionStateJson()
fun getLatestPose2D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose2D()
fun getLatestPose3D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose3D()
fun getLatestRuntimeStats(): FloatArray = KiwiiRuntimeClientBridge.getLatestRuntimeStats()
fun getLatestPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestPoseFrameJson()
fun getLatestBodyPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestBodyPoseFrameJson()
fun getLeftHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLeftHandleIMULatest()
fun getLeftHandleIMUQueue(): Array<FloatArray> {
val queue = readHandleImuQueueCompat("getLeftHandleIMUQueue")
if (queue.isNotEmpty()) return queue
val latest = getLeftHandleIMULatest()
return if (latest.isEmpty()) emptyArray() else arrayOf(latest)
}
fun submitLeftStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitLeftStaticEffect(effectId)
fun submitLeftHeStream(streamId: Int, payload: String): String =
KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, buildHeStreamPatternJson(streamId, payload))
fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest()
fun getRightHandleIMUQueue(): Array<FloatArray> {
val queue = readHandleImuQueueCompat("getRightHandleIMUQueue")
if (queue.isNotEmpty()) return queue
val latest = getRightHandleIMULatest()
return if (latest.isEmpty()) emptyArray() else arrayOf(latest)
}
fun submitRightStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitRightStaticEffect(effectId)
fun submitRightHeStream(streamId: Int, payload: String): String =
KiwiiRuntimeClientBridge.submitRightHeStream(streamId, buildHeStreamPatternJson(streamId, payload))
fun getHandleStateJson(): String {
val json = KiwiiRuntimeClientBridge.getHandleStateJson()
cacheHandleStateIfValid(json)
return json
}
/**
* Phase4B-v11.9: best-effort live HandleState refresh without blocking UI/poller callers.
*
* Important: some RuntimeHost debug/binder paths can block after long idle. A stuck refresh
* must not permanently disable future refresh attempts, so the in-flight flag is allowed to
* expire. The cached/last-known snapshot is never cleared on failure.
*/
fun requestHandleStateRefreshAsync(reason: String = "") {
if (!isBound()) return
val now = System.currentTimeMillis()
val stuck = handleRefreshInFlight.get() &&
lastHandleRefreshAttemptMs > 0L &&
now - lastHandleRefreshAttemptMs > HANDLE_REFRESH_STUCK_RESET_MS
if (stuck) {
handleRefreshInFlight.set(false)
}
if (now - lastHandleRefreshAttemptMs < HANDLE_REFRESH_MIN_INTERVAL_MS) return
if (!handleRefreshInFlight.compareAndSet(false, true)) return
lastHandleRefreshAttemptMs = now
handleRefreshExecutor.execute {
try {
val json = KiwiiRuntimeClientBridge.getHandleStateJson()
cacheHandleStateIfValid(json)
} catch (_: Throwable) {
// Keep last-known snapshot. Never clear cached handle state on refresh failure.
} finally {
handleRefreshInFlight.set(false)
}
}
}
fun getCachedRawHandleStateJsonOrUnavailable(): String {
val now = System.currentTimeMillis()
val cacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
return if (cachedHandleStateJson.isNotBlank()) {
val obj = parseJsonOrEmpty(cachedHandleStateJson)
obj.put("uiPath", "SDK_PANEL_CACHED_HANDLE_STATE")
obj.put("cacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs)
obj.put("cacheFresh", cacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS)
obj.toString()
} else {
JSONObject()
.put("error", "no-cached-handle-state")
.put("source", "SDK_Panel.cached-handle-state.v11.18")
.put("cacheAgeMs", -1L)
.put("cacheFresh", false)
.toString()
}
}
/**
* Phase4B-v11.17: best-effort live Dongle transport refresh without blocking
* home-card rendering. This reads RuntimeHost's fast transport-only dongle state;
* the cached result is then used by both the home card and readDongleState detail.
*/
fun requestDongleStateRefreshAsync(reason: String = "") {
if (!isBound()) return
val now = System.currentTimeMillis()
val stuck = dongleRefreshInFlight.get() &&
lastDongleRefreshAttemptMs > 0L &&
now - lastDongleRefreshAttemptMs > DONGLE_REFRESH_STUCK_RESET_MS
if (stuck) {
dongleRefreshInFlight.set(false)
}
if (now - lastDongleRefreshAttemptMs < DONGLE_REFRESH_MIN_INTERVAL_MS) return
if (!dongleRefreshInFlight.compareAndSet(false, true)) return
lastDongleRefreshAttemptMs = now
dongleRefreshExecutor.execute {
try {
val json = KiwiiRuntimeClientBridge.getDongleStateJson()
cacheDongleStateIfValid(json)
} catch (_: Throwable) {
// Keep last-known dongle state. Never clear cached state on refresh failure.
} finally {
dongleRefreshInFlight.set(false)
}
}
}
fun getCachedRawDongleStateJsonOrUnavailable(): String {
val now = System.currentTimeMillis()
val cacheAgeMs = if (cachedDongleStateAtMs > 0L) now - cachedDongleStateAtMs else Long.MAX_VALUE
return if (cachedDongleStateJson.isNotBlank()) {
val obj = parseJsonOrEmpty(cachedDongleStateJson)
obj.put("uiPath", "SDK_PANEL_CACHED_DONGLE_STATE")
obj.put("cacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs)
obj.put("cacheFresh", cacheAgeMs in 0..DONGLE_CACHE_MAX_AGE_MS)
obj.toString()
} else {
JSONObject()
.put("contractVersion", "kiwii.sdk-panel.cached-dongle-state.v11.17")
.put("error", "no-cached-dongle-state")
.put("source", "SDK_Panel.cached-dongle-state")
.put("cacheAgeMs", -1L)
.put("cacheFresh", false)
.toString()
}
}
private fun cacheDongleStateIfValid(json: String) {
if (json.isNotBlank() && !json.contains("\"error\"")) {
cachedDongleStateJson = json
cachedDongleStateAtMs = System.currentTimeMillis()
}
}
private fun cacheHandleStateIfValid(json: String) {
if (json.isNotBlank() && !json.contains("\"error\"")) {
cachedHandleStateJson = json
cachedHandleStateAtMs = System.currentTimeMillis()
}
}
/**
* Phase4B-v11.9: last-known, no-blocking Handle detail data path.
*
* Left/Right Handle controls must preserve the three diagnostics users rely on:
* 1) IMU latest, 2) IMU queue / ObservationBuffer summary, 3) overall HandleState.
* However, the raw AAR methods for latest/queue/state can block after long idle periods.
* These safe helpers therefore read only the last-known HandleState cached by
* asynchronous refresh and return immediately. They never perform live binder calls.
*/
fun getCachedHandleStateJsonForUi(side: String = ""): String {
val now = System.currentTimeMillis()
val cacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
val hasLastKnown = cachedHandleStateJson.isNotBlank()
val fresh = hasLastKnown && cacheAgeMs <= HANDLE_CACHE_MAX_AGE_MS
val handle = if (hasLastKnown) parseJsonOrEmpty(cachedHandleStateJson) else JSONObject()
val sideKey = normalizeHandleSide(side)
val sideObj = if (sideKey.isNotBlank()) handle.optJSONObject(sideKey) ?: JSONObject() else JSONObject()
return JSONObject()
.put("contractVersion", "kiwii.sdk-panel.handle-last-known-state.v11.9")
.put("source", "SDK_Panel.last-known-handle-state")
.put("noLiveBinderCall", true)
.put("cacheFresh", fresh)
.put("cacheStale", hasLastKnown && !fresh)
.put("cacheAgeMs", if (cacheAgeMs == Long.MAX_VALUE) -1L else cacheAgeMs)
.put("side", sideKey.ifBlank { "both" })
.put("sideState", sideObj)
.put("handleState", if (hasLastKnown) handle else JSONObject()
.put("error", "no-cached-handle-state")
.put("detail", "No last-known HandleState yet. Live binder query is intentionally skipped on UI button/state path."))
.toString()
}
fun getCachedHandleIMULatestText(side: String): String {
val snapshot = cachedHandleSnapshot(side)
if (!snapshot.hasLastKnown) {
return "NO_CACHED_HANDLE_STATE\n" +
"side=${snapshot.side}\n" +
"detail=No last-known HandleState yet; live binder query skipped."
}
val h = snapshot.sideJson
val freshness = if (snapshot.cacheFresh) "FRESH" else "STALE"
val q = formatJsonArray(h.optJSONArray("quaternion"))
val acc = formatJsonArray(h.optJSONArray("accelerationG"))
return "IMU Latest (last-known HandleState / $freshness)\n" +
"side=${snapshot.side}\n" +
"available=${h.optBooleanFlexible("available")} connected=${h.optBooleanFlexible("connected")} streaming=${h.optBooleanFlexible("streaming")}\n" +
"seq=${h.optLong("sequenceId", h.optLong("seq", 0L))} dataAgeMs=${h.optLong("dataAgeMs", -1L)} cacheAgeMs=${snapshot.cacheAgeMs}\n" +
"latestArrayLength=${h.optInt("latestArrayLength", 0)} source=${h.optString("source", "")}\n" +
"q=$q\n" +
"accG=$acc"
}
fun getCachedHandleIMUQueueText(side: String): String {
val snapshot = cachedHandleSnapshot(side)
if (!snapshot.hasLastKnown) {
return "NO_CACHED_HANDLE_STATE\n" +
"side=${snapshot.side}\n" +
"detail=No last-known ObservationBuffer summary yet; live queue query skipped."
}
val h = snapshot.sideJson
val freshness = if (snapshot.cacheFresh) "FRESH" else "STALE"
val seq = h.optLong("sequenceId", h.optLong("seq", 0L))
val q = formatJsonArray(h.optJSONArray("quaternion"))
val acc = formatJsonArray(h.optJSONArray("accelerationG"))
return "IMU Queue / ObservationBuffer Summary (last-known / $freshness)\n" +
"side=${snapshot.side}\n" +
"queueSize=${h.optInt("queueSize", 0)} latestArrayLength=${h.optInt("latestArrayLength", 0)}\n" +
"seqLatest=$seq dataAgeMs=${h.optLong("dataAgeMs", -1L)} cacheAgeMs=${snapshot.cacheAgeMs}\n" +
"observationBuffer=summary-only; raw frame dump not queried on this UI path\n" +
"latest.q=$q\n" +
"latest.accG=$acc"
}
private data class CachedHandleSnapshot(
val side: String,
val hasLastKnown: Boolean,
val cacheFresh: Boolean,
val cacheAgeMs: Long,
val handleJson: JSONObject,
val sideJson: JSONObject
)
private fun cachedHandleSnapshot(side: String): CachedHandleSnapshot {
val now = System.currentTimeMillis()
val cacheAge = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
val hasLastKnown = cachedHandleStateJson.isNotBlank()
val fresh = hasLastKnown && cacheAge <= HANDLE_CACHE_MAX_AGE_MS
val handle = if (hasLastKnown) parseJsonOrEmpty(cachedHandleStateJson) else JSONObject()
val sideKey = normalizeHandleSide(side)
val sideObj = handle.optJSONObject(sideKey) ?: JSONObject()
return CachedHandleSnapshot(
side = sideKey,
hasLastKnown = hasLastKnown,
cacheFresh = fresh,
cacheAgeMs = if (cacheAge == Long.MAX_VALUE) -1L else cacheAge,
handleJson = handle,
sideJson = sideObj
)
}
private fun normalizeHandleSide(side: String): String {
val s = side.trim().lowercase()
return when {
s.startsWith("l") -> "left"
s.startsWith("r") -> "right"
else -> s
}
}
private fun formatJsonArray(arr: JSONArray?): String {
if (arr == null || arr.length() == 0) return "[]"
val out = ArrayList<String>()
for (i in 0 until arr.length()) {
out.add(String.format(java.util.Locale.US, "%.4f", arr.optDouble(i, 0.0)))
}
return out.joinToString(prefix = "[", postfix = "]")
}
fun setMotorWeightKg(weight: Float): com.kiwii.bridge.MotorCommandResult = KiwiiRuntimeClientBridge.setMotorWeightKg(weight)
fun setMotorTrainingMode(mode: Int, param: Float): String = KiwiiRuntimeClientBridge.setMotorTrainingMode(mode, param)
fun setMotorForceControlParamsJson(paramsJson: String): String {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod("setMotorForceControlParamsJson", String::class.java)
method.invoke(null, paramsJson) as? String ?: "{\"error\":\"setMotorForceControlParamsJson returned null\"}"
} catch (t: Throwable) {
"{\"error\":\"setMotorForceControlParamsJson unavailable: ${t.message}\"}"
}
}
fun queryMotorTrainingMode(axis: Int): String = KiwiiRuntimeClientBridge.queryMotorTrainingMode(axis)
fun getLatestMotorStateJson(): String = KiwiiRuntimeClientBridge.getLatestMotorStateJson()
fun getMotorControlStateJson(): String = KiwiiRuntimeClientBridge.getMotorControlStateJson()
fun getMotorDisconnectDiagnosisJson(): String {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod("getMotorDisconnectDiagnosisJson")
method.invoke(null) as? String ?: "{\"error\":\"getMotorDisconnectDiagnosisJson returned null\"}"
} catch (t: Throwable) {
"{\"error\":\"getMotorDisconnectDiagnosisJson unavailable: ${t.message}\"}"
}
}
fun sendMotorHeartbeat(): String {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod("sendMotorHeartbeat")
method.invoke(null) as? String ?: "{\"error\":\"sendMotorHeartbeat returned null\"}"
} catch (t: Throwable) {
"{\"error\":\"sendMotorHeartbeat unavailable: ${t.message}\"}"
}
}
fun getTelemetryStateJson(): String = KiwiiRuntimeClientBridge.getTelemetryStateJson()
fun getSafetyStateJson(): String = KiwiiRuntimeClientBridge.getSafetyStateJson()
fun dryRunSafetyGate(command: String): String = KiwiiRuntimeClientBridge.dryRunSafetyGate(command)
fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command)
fun getDeviceCommandStateJson(): String = KiwiiRuntimeClientBridge.getDeviceCommandStateJson()
/**
* Phase4B-v11.9 SDK Panel no-blocking detail-state view for Dongle.
*
* Important design decision: this UI path must NOT call raw getDongleStateJson().
* On current RuntimeHost builds that raw dongle query can block behind debug/global
* state paths, causing the Dongle detail State panel to disappear and readDongleState
* to return only a timeout.
*
* For the detail page we therefore use the non-blocking HandleState contract as the
* operational evidence source and explicitly mark raw dongle slot/transport report as
* not queried on this safe UI path. RuntimeHost/SmartBase transport health is still
* validated by the home-card availability poll and logcat; this page is a per-component
* diagnostic view and must remain responsive.
*/
fun getDongleDetailStateJson(): String {
// Phase4B-v11.17: readDongleState remains zero-blocking, but no longer uses a
// fake NOT_QUERIED placeholder as the primary state. The home-card poller keeps a
// cached RuntimeHost dongle transport snapshot; this method displays that cached
// raw state plus inferred slot evidence from cached Handle/Motor snapshots.
requestDongleStateRefreshAsync("read-dongle-state-click")
val now = System.currentTimeMillis()
val handleCacheAgeMs = if (cachedHandleStateAtMs > 0L) now - cachedHandleStateAtMs else Long.MAX_VALUE
val handleRaw = if (cachedHandleStateJson.isNotBlank()) {
cachedHandleStateJson
} else {
JSONObject()
.put("error", "no-cached-handle-state")
.put("handleCacheAgeMs", if (handleCacheAgeMs == Long.MAX_VALUE) -1L else handleCacheAgeMs)
.put("detail", "Safe Dongle detail path uses last-known HandleState only; live binder query skipped to avoid idle timeout.")
.toString()
}
return buildDongleDetailStateJson(
getCachedRawDongleStateJsonOrUnavailable(),
handleRaw,
if (handleCacheAgeMs == Long.MAX_VALUE) -1L else handleCacheAgeMs
)
}
fun getDongleSafePlaceholderStateJson(): String = buildSafeDonglePlaceholderJson()
private fun buildSafeDonglePlaceholderJson(): String {
return JSONObject()
.put("contractVersion", "kiwii.sdk-panel.dongle-safe-placeholder.v11.9")
.put("source", "SDK_Panel.non-blocking-dongle-detail")
.put("rawDongleQuery", "disabled-on-detail-ui-path")
.put("transport", JSONObject()
.put("state", "NOT_QUERIED")
.put("rawDongleQuery", "disabled-on-detail-ui-path")
.put("detail", "Live raw transport query skipped on no-blocking detail path."))
.toString()
}
private fun buildDongleDetailStateJson(dongleRaw: String, handleRaw: String, handleCacheAgeMs: Long): String {
val dongle = parseJsonOrEmpty(dongleRaw)
val handle = parseJsonOrEmpty(handleRaw)
val out = JSONObject()
out.put("contractVersion", "kiwii.sdk-panel.dongle-detail-state.v11.17")
out.put("source", "SDK_Panel.cached-dongle-and-handle-state-detail")
out.put("diagnosticSemantics", "transport-state-first; no-live-binder-call-on-dongle-detail; inferred-slot-report-secondary")
out.put("handleCacheAgeMs", handleCacheAgeMs)
out.put("handleCacheFresh", handleCacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS)
out.put("handleCacheStale", handleCacheAgeMs > HANDLE_CACHE_MAX_AGE_MS)
out.put("generatedAtMs", System.currentTimeMillis())
out.put("dongle", dongle)
out.put("handleState", handle)
val transport = dongle.optJSONObject("transport") ?: JSONObject()
out.put("transport", transport)
out.put("slots", buildSdkPanelSlots(dongle, handle, handleCacheAgeMs))
val evidenceNote = buildSlotEvidenceNote(dongle, handle, handleCacheAgeMs)
if (evidenceNote.isNotBlank()) out.put("slotEvidenceNote", evidenceNote)
return out.toString()
}
private fun buildSdkPanelSlots(dongle: JSONObject, handle: JSONObject, handleCacheAgeMs: Long): JSONArray {
val sourceSlots = firstSlotArray(dongle)
val slots = JSONArray()
val left = handle.optJSONObject("left") ?: JSONObject()
val right = handle.optJSONObject("right") ?: JSONObject()
val motorSlotEvidence = MotorTelemetrySnapshotCache.dongleSlotEvidenceJson()
for (slotIndex in 0..3) {
val reported = findSlotObject(slotIndex, sourceSlots)
val slot = JSONObject()
slot.put("slot", slotIndex)
val inferredHandle = when (slotIndex) {
0 -> if (hasFreshHandleSlotEvidence(left, handleCacheAgeMs)) "LEFT_HANDLE" else ""
1 -> if (hasFreshHandleSlotEvidence(right, handleCacheAgeMs)) "RIGHT_HANDLE" else ""
else -> ""
}
if (slotIndex == 3 && motorSlotEvidence != null) {
val keys = motorSlotEvidence.keys()
while (keys.hasNext()) {
val key = keys.next()
slot.put(key, motorSlotEvidence.opt(key))
}
} else if (inferredHandle.isNotBlank()) {
val handleObj = if (slotIndex == 0) left else right
slot.put("state", "CONNECTED")
slot.put("connected", true)
slot.put("device", inferredHandle)
slot.put("source", "handle-state-fresh-inferred")
slot.put("inferred", true)
slot.put("sequenceId", handleObj.optLong("sequenceId", handleObj.optLong("seq", 0L)))
slot.put("dataAgeMs", handleObj.optLong("dataAgeMs", -1L))
slot.put("queueSize", handleObj.optInt("queueSize", 0))
slot.put("stale", handleCacheAgeMs > HANDLE_CACHE_MAX_AGE_MS)
slot.put("evidenceAgeMs", handleCacheAgeMs)
val reportedState = reported?.optString("state", reported.optString("status", "")) ?: ""
val rawSlotReport = when {
reported == null -> if (dongle.optString("rawDongleQuery", "") == "disabled-on-detail-ui-path") "not-queried" else "missing"
reportedState.isBlank() -> "present-without-state"
reportedState.equals("CONNECTED", ignoreCase = true) -> "connected"
else -> reportedState.lowercase()
}
slot.put("rawSlotReport", rawSlotReport)
if (reportedState.isNotBlank() && !reportedState.equals("CONNECTED", ignoreCase = true)) {
slot.put("detail", "Connected via fresh handle-state; rawSlot=$rawSlotReport")
} else if (reported == null) {
slot.put("detail", "Connected via fresh handle-state; rawSlot=$rawSlotReport")
} else {
slot.put("detail", "Connected via fresh handle-state")
}
} else if (reported != null) {
val connected = reported.optBooleanFlexible("connected") ||
reported.optBooleanFlexible("available") ||
reported.optBooleanFlexible("active")
val state = firstNonBlank(
reported.optString("state", ""),
reported.optString("status", ""),
if (connected) "CONNECTED" else "DISCONNECTED"
)
slot.put("state", state)
slot.put("connected", connected)
slot.put("source", "dongle-slot-report")
slot.put("inferred", false)
copyIfPresent(reported, slot, "dev")
copyIfPresent(reported, slot, "device")
copyIfPresent(reported, slot, "deviceType")
copyIfPresent(reported, slot, "deviceId")
copyIfPresent(reported, slot, "sequenceId")
copyIfPresent(reported, slot, "seq")
copyIfPresent(reported, slot, "dataAgeMs")
copyIfPresent(reported, slot, "ageMs")
slot.put("detail", "Reported by dongle JSON")
} else {
slot.put("state", "UNKNOWN")
slot.put("connected", false)
slot.put("source", "missing-slot-report")
slot.put("inferred", false)
slot.put("detail", if (dongle.optString("rawDongleQuery", "") == "disabled-on-detail-ui-path") "Raw slot skipped" else "No raw slot report")
}
slots.put(slot)
}
return slots
}
private fun buildSlotEvidenceNote(dongle: JSONObject, handle: JSONObject, handleCacheAgeMs: Long): String {
val sourceSlots = firstSlotArray(dongle)
val right = handle.optJSONObject("right") ?: JSONObject()
val rightFresh = hasFreshHandleSlotEvidence(right, handleCacheAgeMs)
val reportedSlot1 = findSlotObject(1, sourceSlots)
val reportedSlot1Connected = reportedSlot1?.let {
it.optBooleanFlexible("connected") || it.optBooleanFlexible("available") || it.optBooleanFlexible("active") ||
it.optString("state", "").equals("CONNECTED", ignoreCase = true) ||
it.optString("status", "").equals("CONNECTED", ignoreCase = true)
} ?: false
val notes = mutableListOf<String>()
if (rightFresh && !reportedSlot1Connected) notes += "Slot 1 inferred from cached Right Handle data."
if (MotorTelemetrySnapshotCache.dongleSlotEvidenceJson() != null) notes += "Slot 3 inferred from explicit Motor runtime state."
return notes.joinToString(" ")
}
private fun firstSlotArray(obj: JSONObject): JSONArray? {
obj.optJSONArray("slots")?.let { return it }
obj.optJSONArray("slotStatus")?.let { return it }
obj.optJSONArray("slotStates")?.let { return it }
obj.optJSONObject("dongle")?.optJSONArray("slots")?.let { return it }
obj.optJSONObject("status")?.optJSONArray("slots")?.let { return it }
return null
}
private fun findSlotObject(slotIndex: Int, slots: JSONArray?): JSONObject? {
if (slots == null) return null
for (i in 0 until slots.length()) {
val obj = slots.optJSONObject(i) ?: continue
val reportedIndex = obj.optIntOrNull("slot") ?: obj.optIntOrNull("slotId") ?: obj.optIntOrNull("index")
if (reportedIndex == slotIndex) return obj
}
return slots.optJSONObject(slotIndex)
}
private fun hasFreshHandleSlotEvidence(handle: JSONObject, handleCacheAgeMs: Long): Boolean {
val available = handle.optBooleanFlexible("available")
val connected = handle.optBooleanFlexible("connected")
val streaming = handle.optBooleanFlexible("streaming")
val seq = handle.optLong("sequenceId", handle.optLong("seq", 0L))
val queueSize = handle.optInt("queueSize", 0)
val dataAgeMs = handle.optLong("dataAgeMs", Long.MAX_VALUE)
val cacheFresh = handleCacheAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS
val dataFresh = dataAgeMs in 0..HANDLE_CACHE_MAX_AGE_MS
return cacheFresh && dataFresh && (available || connected || streaming) && seq > 0L && queueSize > 0
}
private fun parseJsonOrEmpty(raw: String): JSONObject {
return try {
JSONObject(raw)
} catch (_: Throwable) {
JSONObject().put("raw", raw.take(500))
}
}
private fun copyIfPresent(src: JSONObject, dst: JSONObject, key: String) {
if (src.has(key)) dst.put(key, src.opt(key))
}
private fun JSONObject.optBooleanFlexible(key: String): Boolean {
if (!has(key)) return false
val raw = opt(key) ?: return false
return when (raw) {
is Boolean -> raw
is Number -> raw.toInt() != 0
is String -> raw.equals("true", ignoreCase = true) || raw == "1" || raw.equals("yes", ignoreCase = true) || raw.equals("connected", ignoreCase = true) || raw.equals("active", ignoreCase = true) || raw.equals("running", ignoreCase = true)
else -> false
}
}
private fun JSONObject.optIntOrNull(key: String): Int? {
if (!has(key)) return null
return try { optInt(key) } catch (_: Throwable) { null }
}
private fun firstNonBlank(vararg values: String): String = values.firstOrNull { it.isNotBlank() } ?: ""
private fun escapeJson(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"")
private fun readHandleImuQueueCompat(methodName: String): Array<FloatArray> {
return try {
val method = KiwiiRuntimeClientBridge::class.java.getMethod(methodName)
when (val value = method.invoke(null)) {
is Array<*> -> {
@Suppress("UNCHECKED_CAST")
value.filterIsInstance<FloatArray>().toTypedArray()
}
is FloatArray -> splitFlatImuQueue(value)
else -> emptyArray()
}
} catch (_: Throwable) {
emptyArray()
}
}
private fun splitFlatImuQueue(flat: FloatArray): Array<FloatArray> {
if (flat.isEmpty()) return emptyArray()
val sampleSize = when {
flat.size % 10 == 0 -> 10
flat.size % 9 == 0 -> 9
flat.size % 7 == 0 -> 7
else -> flat.size
}
if (sampleSize <= 0) return emptyArray()
val out = ArrayList<FloatArray>()
var offset = 0
while (offset < flat.size) {
val end = minOf(offset + sampleSize, flat.size)
out.add(flat.copyOfRange(offset, end))
offset = end
}
return out.toTypedArray()
}
private fun buildHeStreamPatternJson(streamId: Int, payload: String): String {
val trimmed = payload.trim()
if (trimmed.startsWith("{")) return trimmed
val escaped = payload.replace("\\", "\\\\").replace("\"", "\\\"")
return "{\"streamId\":$streamId,\"payload\":\"$escaped\"}"
}
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,604 @@
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 com.kiwii.controlpanel.ui.components.MotorTelemetrySnapshotCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
class ComponentRegistry(
private val repo: BridgeRepository,
private val scope: CoroutineScope,
private val logger: SessionLogger
) {
private val _availability = MutableStateFlow<Map<ComponentType, ComponentState>>(emptyMap())
val availability: StateFlow<Map<ComponentType, ComponentState>> = _availability
private var previousStates = emptyMap<ComponentType, ComponentState>()
private companion object {
const val TAG = "KiwiiSDKPanelState"
const val HANDLE_DATA_AGE_FRESH_TIMEOUT_MS = 1500L
const val HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L
const val DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS = 2500L
const val DONGLE_TRANSPORT_FRESH_TIMEOUT_MS = 3_000L
const val MOTOR_STATUS_FRESH_TIMEOUT_MS = 5_000L
const val SLOT_STATUS_FRESH_TIMEOUT_MS = 5_000L
const val DONGLE_RULE = "REQUIRE_FRESH_SMARTBASE_USB_TRANSPORT"
}
private data class SlotSignal(
val slot: Int = -1,
val dev: Int = 0,
val name: String = "",
val state: String = "",
val connected: Boolean = false,
val lastSeenAgeMs: Long = Long.MAX_VALUE,
val source: String = ""
) {
val freshConnected: Boolean
get() = connected && lastSeenAgeMs in 0..5_000L
}
private data class HandleSignal(
val side: String,
val available: Boolean = false,
val connected: Boolean = false,
val streaming: Boolean = false,
val sequenceId: Long = 0L,
val dataAgeMs: Long = -1L,
val queueSize: Int = 0,
val health: String = "",
val source: String = "",
val slot: SlotSignal = SlotSignal()
)
private data class DongleSignal(
val valid: Boolean = false,
val active: Boolean = false,
val usbPresent: Boolean = false,
val usbBridgeRegistered: Boolean = false,
val readLoopRunning: Boolean = false,
val stale: Boolean = true,
val dataStale: Boolean = true,
val lastUsbInAgeMs: Long = Long.MAX_VALUE,
val cacheAgeMs: Long = Long.MAX_VALUE,
val state: String = "",
val source: String = "",
val evidence: String = ""
)
private data class HandleDashboardSignal(
val valid: Boolean,
val left: HandleSignal = HandleSignal("left"),
val right: HandleSignal = HandleSignal("right"),
val dongleStateLocated: Boolean = false,
val observationBufferLocated: Boolean = false,
val dongle: DongleSignal = DongleSignal(),
val motorSlot: SlotSignal = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER"),
val leftProgressAgeMs: Long = Long.MAX_VALUE,
val rightProgressAgeMs: Long = Long.MAX_VALUE,
val dongleProgressAgeMs: Long = Long.MAX_VALUE,
val handleCacheAgeMs: Long = Long.MAX_VALUE
)
@Volatile
private var latestDongleCardStateForMotorGate: ComponentState = ComponentState.GREY
@Volatile
private var latestHandleSignalsForMotorSlot: SlotSignal? = null
private var lastLeftSequenceId: Long = 0L
private var lastRightSequenceId: Long = 0L
private var lastLeftSequenceProgressWallMs: Long = 0L
private var lastRightSequenceProgressWallMs: Long = 0L
private var lastDongleSequenceProgressWallMs: Long = 0L
fun startDetection() {
scope.launch(Dispatchers.IO) {
while (isActive) {
val bound = repo.isBound()
if (bound) {
repo.requestHandleStateRefreshAsync("availability-poll")
repo.requestDongleStateRefreshAsync("availability-poll")
MotorTelemetrySnapshotCache.requestRuntimeStateRefreshAsync(repo, "availability-poll")
}
val handleJson = if (bound) repo.getCachedRawHandleStateJsonOrUnavailable() else "{\"error\":\"NOT_BOUND\"}"
// Phase4B-v11.9: home-card availability must be cold-start safe and never block on
// RuntimeHost debug/binder reads. Camera/Dongle/Telemetry home cards use binder-bound
// runtime availability; detailed state buttons remain no-blocking/last-known.
val runtimeStats = FloatArray(0)
val cameraJson = if (bound) "{\"contractVersion\":\"kiwii.sdk-panel.camera-bound-placeholder.v11.9\",\"state\":\"BOUND_RUNTIME\",\"running\":true}" else "{\"error\":\"NOT_BOUND\"}"
val dongleJson = if (bound) repo.getCachedRawDongleStateJsonOrUnavailable() else "{\"error\":\"NOT_BOUND\"}"
val handleSignals = updateHandleSignals(bound, handleJson, dongleJson)
val result = ComponentType.entries.associateWith { type ->
checkAvailability(type, bound, handleJson, runtimeStats, cameraJson, handleSignals)
}
logDashboardSummary(bound, result, handleSignals, runtimeStats, cameraJson)
logStateChanges(previousStates, result)
previousStates = result
_availability.value = result
delay(1000L)
}
}
}
private fun updateHandleSignals(bound: Boolean, handleJson: String, dongleJson: String): HandleDashboardSignal {
val now = System.currentTimeMillis()
val dongle = parseDongleSignal(bound, dongleJson)
if (!bound || isUnavailable(handleJson)) {
latestHandleSignalsForMotorSlot = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER", source = "handle-unavailable")
return HandleDashboardSignal(
valid = false,
dongle = dongle,
leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs),
rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs),
dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs)
)
}
return try {
val obj = JSONObject(handleJson)
val handleCacheAgeMs = obj.optLong("cacheAgeMs", Long.MAX_VALUE)
val slots = obj.optJSONArray("dongleSlots")
val leftSlot = parseSlotSignal(slots, 0, 0x11, "LEFT_HANDLE")
val rightSlot = parseSlotSignal(slots, 1, 0x12, "RIGHT_HANDLE")
val motorSlot = parseSlotSignal(slots, 3, 0x31, "MOTOR_POWER")
latestHandleSignalsForMotorSlot = motorSlot
val left = parseHandleSignal("left", obj.optJSONObject("left"), leftSlot)
val right = parseHandleSignal("right", obj.optJSONObject("right"), rightSlot)
val leftProgress = updateSequenceProgress(
side = "left",
sequenceId = left.sequenceId,
hasLiveFlags = left.available || left.connected || left.streaming,
now = now
)
val rightProgress = updateSequenceProgress(
side = "right",
sequenceId = right.sequenceId,
hasLiveFlags = right.available || right.connected || right.streaming,
now = now
)
if (leftProgress || rightProgress) {
lastDongleSequenceProgressWallMs = now
}
HandleDashboardSignal(
valid = true,
left = left,
right = right,
dongleStateLocated = obj.optBoolean("dongleStateLocated", obj.optJSONObject("debugTrace")?.optBoolean("dongleStateLocated", false) ?: false),
observationBufferLocated = obj.optBoolean("observationBufferLocated", obj.optJSONObject("debugTrace")?.optBoolean("observationBufferLocated", false) ?: false),
dongle = dongle,
motorSlot = motorSlot,
leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs),
rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs),
dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs),
handleCacheAgeMs = handleCacheAgeMs
)
} catch (t: Throwable) {
Log.w(TAG, "updateHandleSignals exception: ${t.message}")
latestHandleSignalsForMotorSlot = SlotSignal(slot = 3, dev = 0x31, name = "MOTOR_POWER", source = "parse-exception")
HandleDashboardSignal(
valid = false,
dongle = dongle,
leftProgressAgeMs = ageSince(now, lastLeftSequenceProgressWallMs),
rightProgressAgeMs = ageSince(now, lastRightSequenceProgressWallMs),
dongleProgressAgeMs = ageSince(now, lastDongleSequenceProgressWallMs)
)
}
}
private fun parseHandleSignal(side: String, obj: JSONObject?, fallbackSlot: SlotSignal = SlotSignal()): HandleSignal {
if (obj == null) return HandleSignal(side, slot = fallbackSlot)
val embeddedSlot = SlotSignal(
slot = obj.optInt("slot", fallbackSlot.slot),
dev = obj.optInt("slotDev", fallbackSlot.dev),
name = firstNonBlank(obj.optString("slotName", ""), fallbackSlot.name),
state = firstNonBlank(obj.optString("slotState", ""), fallbackSlot.state),
connected = obj.optBoolean("slotConnected", fallbackSlot.connected),
lastSeenAgeMs = firstNonNegativeLong(obj.optLong("slotLastSeenAgeMs", Long.MIN_VALUE), fallbackSlot.lastSeenAgeMs),
source = "handleState.embeddedSlot"
)
return HandleSignal(
side = side,
available = obj.optBoolean("available", false),
connected = obj.optBoolean("connected", false),
streaming = obj.optBoolean("streaming", false),
sequenceId = obj.optLong("sequenceId", 0L),
dataAgeMs = obj.optLong("dataAgeMs", -1L),
queueSize = obj.optInt("queueSize", 0),
health = obj.optString("health", ""),
source = obj.optString("source", ""),
slot = if (embeddedSlot.slot >= 0) embeddedSlot else fallbackSlot
)
}
private fun parseSlotSignal(slots: org.json.JSONArray?, expectedSlot: Int, expectedDev: Int, expectedName: String): SlotSignal {
if (slots == null) return SlotSignal(slot = expectedSlot, dev = expectedDev, name = expectedName, source = "no-slots")
for (i in 0 until slots.length()) {
val obj = slots.optJSONObject(i) ?: continue
val slot = obj.optInt("slot", -1)
val dev = obj.optInt("dev", 0)
val name = obj.optString("name", "")
if (slot == expectedSlot || dev == expectedDev || name.equals(expectedName, ignoreCase = true)) {
val state = obj.optString("state", "")
return SlotSignal(
slot = slot.takeIf { it >= 0 } ?: expectedSlot,
dev = dev.takeIf { it != 0 } ?: expectedDev,
name = firstNonBlank(name, expectedName),
state = state,
connected = state.equals("CONNECTED", ignoreCase = true) ||
state.equals("CONNECTING", ignoreCase = true) ||
state.equals("DISCOVERED", ignoreCase = true) ||
obj.optBoolean("connected", false),
lastSeenAgeMs = obj.optLong("lastSeenAgeMs", Long.MAX_VALUE),
source = "handleState.dongleSlots"
)
}
}
return SlotSignal(slot = expectedSlot, dev = expectedDev, name = expectedName, source = "slot-not-found")
}
private fun parseDongleSignal(bound: Boolean, json: String): DongleSignal {
if (!bound || isUnavailable(json)) {
return DongleSignal(valid = false, source = "unavailable")
}
return try {
val obj = JSONObject(json)
val transport = obj.optJSONObject("transport") ?: obj
val state = firstNonBlank(
transport.optString("state", ""),
obj.optString("state", ""),
obj.optString("transportState", "")
)
val usbPresent = boolAny(
transport.optBoolean("usbPresent", false),
transport.optBoolean("devicePresent", false),
transport.optBoolean("attached", false),
obj.optBoolean("usbPresent", false),
obj.optBoolean("devicePresent", false),
obj.optBoolean("attached", false)
)
val usbBridgeRegistered = boolAny(
transport.optBoolean("usbBridgeRegistered", false),
transport.optBoolean("bridgeRegistered", false),
obj.optBoolean("usbBridgeRegistered", false),
obj.optBoolean("bridgeRegistered", false)
)
val readLoopRunning = boolAny(
transport.optBoolean("readLoopRunning", false),
transport.optBoolean("bulkInRunning", false),
transport.optBoolean("running", false),
obj.optBoolean("readLoopRunning", false)
)
val activeFlags = boolAny(
obj.optBoolean("active", false),
obj.optBoolean("available", false),
obj.optBoolean("connected", false),
transport.optBoolean("active", false),
transport.optBoolean("available", false),
transport.optBoolean("connected", false),
transport.optBoolean("usbConnected", false),
transport.optBoolean("opened", false),
transport.optBoolean("open", false),
transport.optBoolean("isOpen", false)
)
val stale = boolAny(
obj.optBoolean("stale", false),
obj.optBoolean("dataStale", false),
transport.optBoolean("stale", false),
transport.optBoolean("dataStale", false)
)
val dataStale = boolAny(
obj.optBoolean("dataStale", false),
transport.optBoolean("dataStale", false)
)
val lastUsbInAgeMs = firstNonNegativeLong(
transport.optLong("lastUsbInAgeMs", Long.MIN_VALUE),
obj.optLong("lastUsbInAgeMs", Long.MIN_VALUE)
)
val cacheAgeMs = obj.optLong("cacheAgeMs", Long.MAX_VALUE)
val stateActive = state.equals("CONNECTED", ignoreCase = true) ||
state.equals("RUNNING", ignoreCase = true) ||
state.equals("OPEN", ignoreCase = true) ||
state.equals("OPENED", ignoreCase = true) ||
state.equals("ACTIVE", ignoreCase = true) ||
state.equals("READY", ignoreCase = true)
val usbTrafficFresh = lastUsbInAgeMs in 0..DONGLE_TRANSPORT_FRESH_TIMEOUT_MS
val cacheFresh = cacheAgeMs == Long.MAX_VALUE || cacheAgeMs in 0..DONGLE_TRANSPORT_FRESH_TIMEOUT_MS
val effectiveStale = stale || !cacheFresh || !usbTrafficFresh
val effectiveDataStale = dataStale || !cacheFresh || !usbTrafficFresh
val active = usbPresent &&
usbBridgeRegistered &&
readLoopRunning &&
(activeFlags || stateActive) &&
!effectiveStale &&
usbTrafficFresh &&
cacheFresh
DongleSignal(
valid = true,
active = active,
usbPresent = usbPresent,
usbBridgeRegistered = usbBridgeRegistered,
readLoopRunning = readLoopRunning,
stale = effectiveStale,
dataStale = effectiveDataStale,
lastUsbInAgeMs = lastUsbInAgeMs,
cacheAgeMs = cacheAgeMs,
state = state,
source = obj.optString("source", "getDongleStateJson"),
evidence = "usbPresent=$usbPresent bridge=$usbBridgeRegistered readLoop=$readLoopRunning activeFlags=$activeFlags state=$state rawStale=$stale rawDataStale=$dataStale effectiveStale=$effectiveStale lastUsbInAgeMs=${printAge(lastUsbInAgeMs)} cacheAgeMs=${printAge(cacheAgeMs)}"
)
} catch (t: Throwable) {
Log.w(TAG, "parseDongleSignal exception: ${t.message}")
DongleSignal(valid = false, source = "parse-exception", evidence = t.message ?: "unknown")
}
}
private fun boolAny(vararg values: Boolean?): Boolean = values.any { it == true }
private fun firstNonBlank(vararg values: String?): String {
return values.firstOrNull { !it.isNullOrBlank() } ?: ""
}
private fun firstNonNegativeLong(vararg values: Long): Long {
return values.firstOrNull { it >= 0L } ?: Long.MAX_VALUE
}
private fun updateSequenceProgress(side: String, sequenceId: Long, hasLiveFlags: Boolean, now: Long): Boolean {
if (!hasLiveFlags || sequenceId <= 0L) return false
return when (side) {
"left" -> {
val changed = sequenceId != lastLeftSequenceId
if (changed) {
lastLeftSequenceId = sequenceId
lastLeftSequenceProgressWallMs = now
}
changed
}
"right" -> {
val changed = sequenceId != lastRightSequenceId
if (changed) {
lastRightSequenceId = sequenceId
lastRightSequenceProgressWallMs = now
}
changed
}
else -> false
}
}
private fun ageSince(now: Long, last: Long): Long {
return if (last <= 0L) Long.MAX_VALUE else now - last
}
private fun logDashboardSummary(
bound: Boolean,
states: Map<ComponentType, ComponentState>,
handles: HandleDashboardSignal,
runtimeStats: FloatArray,
cameraJson: String
) {
val statsActive = runtimeStats.any { !it.isNaN() && !it.isInfinite() && it > 0.1f }
val camera = runCatching { JSONObject(cameraJson) }.getOrNull()
val cameraRunning = camera?.optBoolean("running", false) == true || camera?.optString("state", "") == "RUNNING"
val imxOpen = camera?.optBoolean("imx415OpenSucceeded", false) == true ||
camera?.optJSONObject("realInternalCameraPipeline")?.optBoolean("imx415OpenSucceeded", false) == true
Log.i(
TAG,
"Phase4B-v11.9 dashboard availability; bound=$bound; " +
"camera=${states[ComponentType.CAMERA]}; right=${states[ComponentType.RIGHT_HANDLE]}; " +
"dongle=${states[ComponentType.DONGLE]}; telemetry=${states[ComponentType.TELEMETRY_SAFETY]}; motor=${states[ComponentType.MOTOR]}; " +
"right.available=${handles.right.available}; right.connected=${handles.right.connected}; right.streaming=${handles.right.streaming}; " +
"right.seq=${handles.right.sequenceId}; right.dataAgeMs=${handles.right.dataAgeMs}; right.progressAgeMs=${printAge(handles.rightProgressAgeMs)}; right.cacheAgeMs=${printAge(handles.handleCacheAgeMs)}; " +
"right.slotState=${handles.right.slot.state}; right.slotConnected=${handles.right.slot.connected}; right.slotAgeMs=${printAge(handles.right.slot.lastSeenAgeMs)}; " +
"motor.slotState=${handles.motorSlot.state}; motor.slotConnected=${handles.motorSlot.connected}; motor.slotAgeMs=${printAge(handles.motorSlot.lastSeenAgeMs)}; " +
"dongleStateLocated=${handles.dongleStateLocated}; observationBufferLocated=${handles.observationBufferLocated}; dongle.progressAgeMs=${printAge(handles.dongleProgressAgeMs)}; " +
"dongle.usbActive=${handles.dongle.active}; dongle.usbPresent=${handles.dongle.usbPresent}; dongle.bridge=${handles.dongle.usbBridgeRegistered}; dongle.readLoop=${handles.dongle.readLoopRunning}; dongle.transportState=${handles.dongle.state}; " +
"dongle.stale=${handles.dongle.stale}; dongle.dataStale=${handles.dongle.dataStale}; dongle.lastUsbInAgeMs=${printAge(handles.dongle.lastUsbInAgeMs)}; dongle.cacheAgeMs=${printAge(handles.dongle.cacheAgeMs)}; " +
"statsActive=$statsActive; cameraRunning=$cameraRunning; imx415OpenSucceeded=$imxOpen; rule=$DONGLE_RULE"
)
}
private fun printAge(age: Long): String {
return if (age == Long.MAX_VALUE) "NA" else age.toString()
}
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
))
Log.i(TAG, "Phase4B-v11.9 availability_change; component=${type.name}; ${oldState.name}->${newState.name}")
}
}
}
private fun checkAvailability(
type: ComponentType,
bound: Boolean,
handleJson: String,
runtimeStats: FloatArray,
cameraJson: String,
handles: HandleDashboardSignal
): ComponentState {
return when (type.category) {
ComponentCategory.ALWAYS_READY -> ComponentState.ACTIVE
ComponentCategory.RUNTIME_HOST -> checkRuntimeHostComponent(type, bound, runtimeStats, cameraJson)
ComponentCategory.PERIPHERAL -> checkPeripheral(type, bound, handleJson, handles)
}
}
private fun checkRuntimeHostComponent(
type: ComponentType,
bound: Boolean,
runtimeStats: FloatArray,
cameraJson: String
): ComponentState {
if (type == ComponentType.RUNTIME_HOST_STATUS) return ComponentState.ACTIVE
if (!bound) return ComponentState.GREY
return when (type) {
// Phase4B-v11.9: do not let camera/home-card availability depend on a live camera
// debug query during SDK_Panel cold start. Detail pages can still display richer state.
ComponentType.CAMERA -> ComponentState.ACTIVE
ComponentType.TELEMETRY_SAFETY -> ComponentState.ACTIVE
else -> ComponentState.ACTIVE
}
}
private fun checkPeripheral(
type: ComponentType,
bound: Boolean,
handleJson: String,
handles: HandleDashboardSignal
): ComponentState {
if (!bound) return ComponentState.GREY
return when (type) {
ComponentType.LEFT_HANDLE -> checkHandle(handles.left, handles.leftProgressAgeMs)
ComponentType.RIGHT_HANDLE -> checkHandle(handles.right, handles.rightProgressAgeMs)
ComponentType.BALANCE_BOARD -> ComponentState.GREY // CoP not integrated yet.
ComponentType.DONGLE -> checkDongle(handles)
ComponentType.MOTOR -> checkMotor()
else -> ComponentState.GREY
}
}
private fun checkCamera(runtimeStats: FloatArray, cameraJson: String): ComponentState {
// Home card means pipeline alive, not necessarily human detected.
val statsActive = runtimeStats.any { !it.isNaN() && !it.isInfinite() && it > 0.1f }
if (statsActive) return ComponentState.ACTIVE
if (isUnavailable(cameraJson)) return ComponentState.GREY
return try {
val obj = JSONObject(cameraJson)
val pipeline = obj.optJSONObject("realInternalCameraPipeline")
val pose2D = obj.optJSONObject("pose2DState")
val pose3D = obj.optJSONObject("pose3DState")
val running = obj.optBoolean("running", false) || obj.optString("state", "") == "RUNNING"
val imx415Open = obj.optBoolean("imx415OpenSucceeded", false) || pipeline?.optBoolean("imx415OpenSucceeded", false) == true
val rga = obj.optBoolean("rgaEnabled", false) || pipeline?.optBoolean("rgaEnabled", false) == true
val rknn = obj.optBoolean("rknnYoloEnabled", false) || pipeline?.optBoolean("rknnYoloEnabled", false) == true
val videoPose3D = obj.optBoolean("videoPose3DEnabled", false) || pipeline?.optBoolean("videoPose3DEnabled", false) == true
val pose2DAvailable = pose2D?.optBoolean("available", false) == true
val pose3DAvailable = pose3D?.optBoolean("available", false) == true
if (running || imx415Open || (rga && rknn) || videoPose3D || pose2DAvailable || pose3DAvailable) {
ComponentState.ACTIVE
} else {
ComponentState.GREY
}
} catch (t: Throwable) {
Log.w(TAG, "checkCamera exception: ${t.message}")
ComponentState.GREY
}
}
private fun checkHandle(signal: HandleSignal, progressAgeMs: Long): ComponentState {
if (signal.side.isBlank()) return ComponentState.GREY
val liveFlags = signal.available || signal.connected || signal.streaming
val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS
val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS
val hasPayload = signal.sequenceId > 0L && signal.queueSize > 0
val streamingActive = liveFlags && hasPayload && (dataFresh || sequenceProgressFresh)
val slotConnectedFresh = signal.slot.freshConnected
// Phase4B-v11.19: home card should distinguish BLE connection from IMU stream.
// A freshly connected Dongle slot is valid "connected" evidence even before
// handle IMU frames arrive; stale last-known payload still cannot keep it green.
val active = streamingActive || slotConnectedFresh
Log.i(
TAG,
"Phase4B-v11.9 checkHandle; side=${signal.side}; available=${signal.available}; connected=${signal.connected}; streaming=${signal.streaming}; " +
"sequenceId=${signal.sequenceId}; queueSize=${signal.queueSize}; dataAgeMs=${signal.dataAgeMs}; progressAgeMs=${printAge(progressAgeMs)}; " +
"slotState=${signal.slot.state}; slotConnected=${signal.slot.connected}; slotAgeMs=${printAge(signal.slot.lastSeenAgeMs)}; " +
"dataFresh=$dataFresh; sequenceProgressFresh=$sequenceProgressFresh; streamingActive=$streamingActive; slotConnectedFresh=$slotConnectedFresh; " +
"decision=${if (active) "ACTIVE" else "GREY"}; rule=REQUIRE_FRESH_DATA_OR_FRESH_SLOT_CONNECTION"
)
return if (active) ComponentState.ACTIVE else ComponentState.GREY
}
private fun checkDongle(handles: HandleDashboardSignal): ComponentState {
val rightFresh = isHandleFreshForDongle(handles.right, handles.rightProgressAgeMs)
val leftFresh = isHandleFreshForDongle(handles.left, handles.leftProgressAgeMs)
val dongleProgressFresh = handles.dongleProgressAgeMs <= DONGLE_SEQUENCE_PROGRESS_TIMEOUT_MS
val recentHandleProgress = dongleProgressFresh && (rightFresh || leftFresh)
// Phase4B-v11.17: Dongle card means SmartBase USB/CDC transport is actually alive.
// Do not mark it ACTIVE merely because RuntimeHost is bound, a placeholder exists,
// or a last-known Handle sample exists. Handles are useful detail-page slot evidence,
// but they are not sufficient transport evidence for the Dongle home card.
val transportActive = handles.dongle.valid && handles.dongle.active
val active = transportActive
val state = if (active) ComponentState.ACTIVE else ComponentState.GREY
latestDongleCardStateForMotorGate = state
Log.i(
TAG,
"Phase4B-v11.9 checkDongle; valid=${handles.valid}; dongleStateLocated=${handles.dongleStateLocated}; observationBufferLocated=${handles.observationBufferLocated}; " +
"transportActive=$transportActive; usbPresent=${handles.dongle.usbPresent}; bridge=${handles.dongle.usbBridgeRegistered}; readLoop=${handles.dongle.readLoopRunning}; transportState=${handles.dongle.state}; " +
"stale=${handles.dongle.stale}; dataStale=${handles.dongle.dataStale}; lastUsbInAgeMs=${printAge(handles.dongle.lastUsbInAgeMs)}; cacheAgeMs=${printAge(handles.dongle.cacheAgeMs)}; " +
"rightFresh=$rightFresh; leftFresh=$leftFresh; recentHandleProgress=$recentHandleProgress; dongleProgressAgeMs=${printAge(handles.dongleProgressAgeMs)}; decision=${state.name}; " +
"rule=$DONGLE_RULE; evidence=${handles.dongle.evidence}"
)
return state
}
private fun isHandleFreshForDongle(signal: HandleSignal, progressAgeMs: Long): Boolean {
val liveFlags = signal.available || signal.connected || signal.streaming
val dataFresh = signal.dataAgeMs in 0..HANDLE_DATA_AGE_FRESH_TIMEOUT_MS
val sequenceProgressFresh = progressAgeMs <= HANDLE_SEQUENCE_PROGRESS_TIMEOUT_MS
return signal.slot.freshConnected || (liveFlags && signal.sequenceId > 0L && signal.queueSize > 0 && (dataFresh || sequenceProgressFresh))
}
private fun checkMotor(): ComponentState {
// Phase4B-v11.14: Motor home-card state comes from explicit motor runtime-state
// refreshed on a background thread, not from scatter-chart visibility. This remains
// no-blocking and can become ACTIVE before the user opens the Motor detail page.
val dongleActive = latestDongleCardStateForMotorGate == ComponentState.ACTIVE
val motor = MotorTelemetrySnapshotCache.homeCardRuntimeStatus(MOTOR_STATUS_FRESH_TIMEOUT_MS)
val motorSlotConnectedFresh = latestHandleSignalsForMotorSlot?.freshConnected ?: false
val active = dongleActive && (motor.active || motorSlotConnectedFresh)
val state = if (active) ComponentState.ACTIVE else ComponentState.GREY
Log.i(
TAG,
"Phase4B-v11.14 checkMotor; dongleActive=$dongleActive; " +
"contractOk=${motor.contractOk}; available=${motor.available}; connected=${motor.connected}; state=${motor.state}; " +
"slotState=${latestHandleSignalsForMotorSlot?.state ?: ""}; slotConnected=${latestHandleSignalsForMotorSlot?.connected ?: false}; slotAgeMs=${printAge(latestHandleSignalsForMotorSlot?.lastSeenAgeMs ?: Long.MAX_VALUE)}; " +
"blockedByDongleTransport=${motor.blockedByDongleTransport}; blockedByMotorTelemetry=${motor.blockedByMotorTelemetry}; " +
"dataAgeMs=${printAge(motor.dataAgeMs)}; snapshotAgeMs=${printAge(motor.snapshotAgeMs)}; " +
"reason=${if (motor.active) motor.reason else if (motorSlotConnectedFresh) "MOTOR_SLOT_CONNECTED_NO_TELEMETRY" else motor.reason}; decision=${state.name}; rule=EXPLICIT_MOTOR_RUNTIME_STATE_OR_FRESH_SLOT_CONNECTION"
)
return state
}
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,26 @@
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 -> "{\"contractVersion\":\"kiwii.sdk-panel.balance-placeholder.v1\",\"available\":false,\"reason\":\"Balance Board detail state is not wired yet\"}"
// Phase4B-v11: Dongle detail state must include transport + slot0..3 + handle-state inference.
// Do not use raw getDongleStateJson() directly for the detail State panel because slot cache can be empty/stale.
ComponentType.DONGLE -> bridge.getDongleDetailStateJson()
ComponentType.TELEMETRY_SAFETY -> bridge.getTelemetryStateJson()
ComponentType.SESSION_LOG -> "{}"
}
override fun isDebugChannelAvailable(): Boolean = false
}
@@ -0,0 +1,113 @@
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
@Volatile
private var focusedComponent: ComponentType = ComponentType.RUNTIME_HOST_STATUS
fun setFocusedComponent(type: ComponentType) {
focusedComponent = type
}
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 = pollFocusedStates()
_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 pollFocusedStates(): Map<ComponentType, StateSnapshot> {
val now = System.currentTimeMillis()
// Phase4B-v11: detail pages must not poll every component. The previous all-component
// loop called heavy/global APIs such as getDebugSnapshotJson and could starve/hang the
// current page after back/re-enter. Poll RuntimeHost + the currently visible component only.
val types = linkedSetOf(ComponentType.RUNTIME_HOST_STATUS)
if (focusedComponent != ComponentType.RUNTIME_HOST_STATUS && focusedComponent != ComponentType.SESSION_LOG) {
types += focusedComponent
}
return types.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\":\"${escapeJson(e.message ?: e.javaClass.simpleName)}\"}",
updatedAt = now,
isDebugChannel = runtimeStateRepo.isUsingDebugChannel(),
hasError = true
)
}
}.toMap()
}
private fun escapeJson(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"")
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,73 @@
package com.kiwii.controlpanel.ui.adapter
import android.util.Log
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) {
Log.i("KiwiiSDKPanelUI", "Phase4B-v6 bind home card; component=${type.name}; state=${state.name}")
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,79 @@
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 = "Phase 4A Camera Observation\n2D/3D are split into LatestFastState + ObservationBuffer. 3D uses VideoPose3D 27F. Timestamps use hostArrivalNs / hostEstimatedSampleTimeNs."
textSize = 12f
setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle))
setPadding(0, 0, 0, 16)
})
layout.addView(createSection("Camera Observation Display"))
layout.addView(createButton("getCameraObservationStateJson") {
viewModel.executeOperation(ComponentType.CAMERA, "getCameraObservationStateJson") {
viewModel.bridgeRepo.getCameraObservationStateJson()
}
})
layout.addView(createButton("getCameraStateJson") {
viewModel.executeOperation(ComponentType.CAMERA, "getCameraStateJson") {
viewModel.bridgeRepo.getCameraStateJson()
}
})
layout.addView(createButton("getHealthStateJson") {
viewModel.executeOperation(ComponentType.CAMERA, "getHealthStateJson") {
viewModel.bridgeRepo.getHealthStateJson()
}
})
layout.addView(createSection("Pose Payloads"))
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(createSection("Raw Runtime Queries"))
layout.addView(createButton("getPerceptionStateJson") {
viewModel.executeOperation(ComponentType.CAMERA, "getPerceptionStateJson") {
viewModel.bridgeRepo.getPerceptionStateJson()
}
})
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,27 @@
package com.kiwii.controlpanel.ui.components
import android.content.Context
import android.view.View
import com.kiwii.controlpanel.model.ComponentType
import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel
class DongleOps(
context: Context,
private val viewModel: ComponentDetailViewModel
) : BaseOps(context) {
override fun createView(): View {
val layout = verticalLayout()
// Phase4B-v11: one action only. It reads the SDK Panel detail-state JSON and updates
// both State panel and Session Log. It does not mutate RuntimeHost state.
layout.addView(createSection("Dongle Diagnostics"))
layout.addView(createButton("readDongleState") {
viewModel.executeOperation(ComponentType.DONGLE, "readDongleState") {
viewModel.bridgeRepo.getDongleDetailStateJson()
}
})
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,224 @@
package com.kiwii.controlpanel.ui.components
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.widget.LinearLayout
import android.widget.TextView
import com.kiwii.controlpanel.R
import com.kiwii.controlpanel.data.BridgeRepository
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
/**
* Phase4B-v11.17 Motor chart lifecycle/cache-poll fix.
*
* Design:
* - Do not change Motor home-card connectivity semantics.
* - Do not change Dongle / Handle / Camera / Session Log behavior.
* - Keep the v11.14 explicit Motor runtime-state cache as the source of truth.
* - Fix Motor page re-entry by restarting the chart poller from onAttachedToWindow().
* - Keep the poller off the UI thread and catch all failures so one timeout/exception does
* not permanently kill the chart refresh loop.
* - Do not let the chart scheduler block on a live Binder call. It requests the shared
* MotorTelemetrySnapshotCache refresh asynchronously, then renders the last-known fresh sample.
*/
internal class MotorRealtimeTelemetryPanel(
context: Context,
private val repo: BridgeRepository
) : LinearLayout(context) {
private val mainHandler = Handler(Looper.getMainLooper())
private val chartView = MotorTelemetryScatterChartView(context)
private val headline = TextView(context)
private val temperatureLine = TextView(context)
private val subline = TextView(context)
@Volatile
private var executor: ScheduledExecutorService? = null
@Volatile
private var attached = false
private var renderedCount: Long = 0L
private var lastRenderedWallTimeMs: Long = -1L
private var lastPollLogMs: Long = 0L
init {
orientation = VERTICAL
setBackgroundResource(R.drawable.bg_rhs_log_area)
setPadding(8.dp(), 8.dp(), 8.dp(), 8.dp())
addView(TextView(context).apply {
text = "Realtime force / rope length / temperature"
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.rgb(38, 50, 56))
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(headline.apply {
text = "Force: — kgf Rope: — cm"
textSize = 13f
typeface = Typeface.MONOSPACE
setTextColor(Color.rgb(20, 32, 40))
setPadding(0, 6.dp(), 0, 0)
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(temperatureLine.apply {
text = "Temp: — °C"
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.rgb(20, 32, 40))
setPadding(0, 3.dp(), 0, 0)
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(subline.apply {
text = "Phase4B-v11.17 cached telemetry poll; buttons use last-known snapshot"
textSize = 10f
setTextColor(Color.rgb(96, 111, 123))
setPadding(0, 2.dp(), 0, 6.dp())
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(chartView, LayoutParams(LayoutParams.MATCH_PARENT, 320.dp()))
addView(TextView(context).apply {
text = "Display convention: force is shown as positive kgf resistance/tension. This is motor-side telemetry derived from current/torque and fm_rad, not a calibrated external load-cell reading. RuntimeHost keeps rawForceN for native-sign debugging. Rope length uses extensionM, or -positionRad × fm_rad fallback."
textSize = 9.5f
setTextColor(Color.rgb(96, 111, 123))
gravity = Gravity.START
setPadding(0, 6.dp(), 0, 0)
}, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
attached = true
startPolling()
}
override fun onDetachedFromWindow() {
attached = false
stopPolling()
super.onDetachedFromWindow()
}
private fun startPolling() {
val current = executor
if (current != null && !current.isShutdown && !current.isTerminated) return
Log.i(TAG, "Phase4B-v11.17 MotorTelemetryPanel start; cachePoll=true; directBinderOnScheduler=false; lifecycleSafe=true")
executor = Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "kiwii-motor-telemetry-panel-v11.17").apply { isDaemon = true }
}.also { exec ->
exec.scheduleWithFixedDelay({
runCatching { pollOnce() }
.onFailure { t ->
val reason = t.message ?: t.javaClass.simpleName
MotorTelemetrySnapshotCache.recordMotorStateError("motor-chart-poll failed: $reason")
Log.w(TAG, "Phase4B-v11.17 MotorTelemetryPanel poll failed: $reason")
postUnavailable("chart poll failed: $reason")
}
}, 0L, 200L, TimeUnit.MILLISECONDS)
}
}
private fun stopPolling() {
Log.i(TAG, "Phase4B-v11.17 MotorTelemetryPanel stop")
executor?.shutdownNow()
executor = null
mainHandler.removeCallbacksAndMessages(null)
}
private fun pollOnce() {
if (!attached) return
if (!repo.isBound()) {
postUnavailable("RuntimeHost not bound")
return
}
// This call is non-blocking for the chart scheduler. The actual Binder read runs in
// MotorTelemetrySnapshotCache's daemon refresh thread with stuck-refresh recovery.
MotorTelemetrySnapshotCache.requestRuntimeStateRefreshAsync(repo, "motor-chart-poll")
val snapshot = MotorTelemetrySnapshotCache.chartSnapshot(freshTimeoutMs = 2_000L)
val sample = snapshot.sample
if (sample == null) {
val reason = "${snapshot.reason}; effectiveAgeMs=${printAge(snapshot.effectiveAgeMs)}; snapshotAgeMs=${printAge(snapshot.snapshotAgeMs)}"
logPollNoSample(reason)
postUnavailable(reason)
} else {
if (sample.wallTimeMs == lastRenderedWallTimeMs) {
logPollNoSample("NO_NEW_SAMPLE; effectiveAgeMs=${printAge(snapshot.effectiveAgeMs)}; snapshotAgeMs=${printAge(snapshot.snapshotAgeMs)}")
}
postSample(sample, snapshot.effectiveAgeMs)
}
}
private fun postSample(sample: MotorTelemetrySample, effectiveAgeMs: Long) {
mainHandler.post {
if (!attached) return@post
updateSample(sample, effectiveAgeMs)
}
}
private fun postUnavailable(reason: String) {
mainHandler.post {
if (!attached) return@post
setUnavailable(reason)
}
}
private fun updateSample(sample: MotorTelemetrySample, effectiveAgeMs: Long) {
headline.text = "Force: ${fmt(sample.forceKg)} kgf Rope: ${fmt(sample.ropeLengthM * 100.0)} cm"
temperatureLine.text = "Temp: ${fmt(sample.tempC)} °C"
subline.text = "Force=${fmt(sample.forceN)} N I=${fmt(sample.currentA)} A V=${fmt(sample.voltageV)} V age=${printAge(effectiveAgeMs)} ms"
if (sample.wallTimeMs != lastRenderedWallTimeMs) {
chartView.addSample(sample)
lastRenderedWallTimeMs = sample.wallTimeMs
renderedCount += 1L
if (renderedCount == 1L || renderedCount % 10L == 0L) {
Log.i(TAG, "Phase4B-v11.17 MotorTelemetryPanel render; count=$renderedCount; sampleWallTimeMs=${sample.wallTimeMs}; dataAgeMs=${sample.dataAgeMs}; effectiveAgeMs=$effectiveAgeMs")
}
}
}
private fun setUnavailable(reason: String) {
headline.text = "Force: — kgf Rope: — cm"
temperatureLine.text = "Temp: — °C"
subline.text = reason
}
private fun logPollNoSample(reason: String) {
val now = System.currentTimeMillis()
if (now - lastPollLogMs < 1000L) return
lastPollLogMs = now
Log.i(TAG, "Phase4B-v11.17 MotorTelemetryPanel poll; running=true; newSample=false; lastRenderedSampleWallTimeMs=$lastRenderedWallTimeMs; reason=$reason")
}
private fun printAge(ageMs: Long): String {
return if (ageMs == Long.MAX_VALUE) "NA" else ageMs.toString()
}
private fun fmt(value: Double): String {
if (!value.isFinite()) return ""
val absValue = kotlin.math.abs(value)
return when {
absValue >= 100.0 -> String.format("%.0f", value)
absValue >= 10.0 -> String.format("%.1f", value)
else -> String.format("%.2f", value)
}
}
private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt()
private companion object {
const val TAG = "KiwiiSDKPanelMotor"
}
}
@@ -0,0 +1,512 @@
package com.kiwii.controlpanel.ui.components
import com.kiwii.controlpanel.data.BridgeRepository
import com.kiwii.controlpanel.logging.LogEntry
import com.kiwii.controlpanel.logging.SessionLogger
import com.kiwii.controlpanel.model.ComponentType
import org.json.JSONObject
import kotlin.math.abs
internal data class MotorHomeCardRuntimeStatus(
val active: Boolean,
val contractOk: Boolean,
val available: Boolean,
val connected: Boolean,
val state: String,
val blockedByDongleTransport: Boolean,
val blockedByMotorTelemetry: Boolean,
val dataAgeMs: Long,
val snapshotAgeMs: Long,
val reason: String
)
internal data class MotorTelemetrySample(
val wallTimeMs: Long,
val motorRuntimeSec: Double,
val forceN: Double,
val forceKg: Double,
val ropeLengthM: Double,
val currentA: Double,
val voltageV: Double,
val tempC: Double,
val dataAgeMs: Long,
val available: Boolean,
val rawJson: String
)
internal data class MotorTelemetryChartSnapshot(
val sample: MotorTelemetrySample?,
val rawJson: String?,
val effectiveAgeMs: Long,
val snapshotAgeMs: Long,
val lastError: String?,
val reason: String
)
internal object MotorTelemetryParser {
private const val STANDARD_GRAVITY_MPS2 = 9.80665
private const val DEFAULT_PULLEY_RADIUS_M = 0.04
private const val DEFAULT_MOTOR_KT_NM_PER_A = 1.0
fun parse(json: String, nowMs: Long = System.currentTimeMillis()): MotorTelemetrySample? {
if (json.isBlank() || json.contains("NOT_BOUND")) return null
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
if (obj.optString("contractVersion", "") != "kiwii.motor-runtime-state.v1") return null
val available = obj.optBoolean("available", false)
val dataAgeMs = obj.optLong("dataAgeMs", Long.MAX_VALUE)
if (!available || dataAgeMs < 0L || dataAgeMs > 2_000L) return null
val pulleyRadiusM = obj.firstFinite(
DEFAULT_PULLEY_RADIUS_M,
"pulleyRadiusM", "fm_rad", "fmRad", "radiusM"
).takeIf { abs(it) > 1e-6 } ?: DEFAULT_PULLEY_RADIUS_M
val motorKt = obj.firstFinite(DEFAULT_MOTOR_KT_NM_PER_A, "motorKtNmPerA", "ktNmPerA")
val currentA = obj.firstFinite(0.0, "currentA", "cur", "iqA", "iq")
val torqueNm = obj.firstFinite(Double.NaN, "torqueNm", "estimatedTorqueNm")
val signedForceN = obj.firstFinite(Double.NaN, "forceN", "outputForceN", "estimatedForceN")
.takeIf { it.isFinite() }
?: run {
val rawForceN = if (torqueNm.isFinite()) torqueNm / pulleyRadiusM else currentA * motorKt / pulleyRadiusM
-rawForceN
}
val forceN = abs(signedForceN)
val forceKg = obj.firstFinite(Double.NaN, "forceKg", "forceKgEquivalent")
.takeIf { it.isFinite() }
?.let { abs(it) }
?: forceN / STANDARD_GRAVITY_MPS2
val positionRad = obj.firstFinite(Double.NaN, "positionRad", "pos")
val ropeLengthM = obj.firstFinite(Double.NaN, "ropeLengthM", "extensionM", "cableLengthM")
.takeIf { it.isFinite() }
?: if (positionRad.isFinite()) -positionRad * pulleyRadiusM else Double.NaN
if (!forceKg.isFinite() || !ropeLengthM.isFinite()) return null
return MotorTelemetrySample(
wallTimeMs = nowMs,
motorRuntimeSec = obj.firstFinite(Double.NaN, "runtimeSec", "t"),
forceN = forceN,
forceKg = forceKg,
ropeLengthM = ropeLengthM,
currentA = currentA,
voltageV = obj.firstFinite(Double.NaN, "voltageV", "volt"),
tempC = obj.firstFinite(Double.NaN, "tempC", "temp"),
dataAgeMs = dataAgeMs,
available = available,
rawJson = json
)
}
private fun JSONObject.firstFinite(fallback: Double, vararg keys: String): Double {
for (key in keys) {
if (!has(key) || isNull(key)) continue
val value = optDouble(key, Double.NaN)
if (value.isFinite()) return value
}
return fallback
}
}
/**
* Phase4B-v11.11 Motor safe UI snapshot cache.
*
* This cache is intentionally local to the Motor UI component package. It avoids changing
* ComponentRegistry, BridgeRepository, Handle, Dongle, Camera, or RuntimeHost status logic.
*
* RuntimeHost motor debug/query methods have been observed to block the SDK Panel operation
* timeout path after idle. The Motor page therefore keeps the chart polling on its own daemon
* thread and exposes non-blocking last-known results for UI buttons.
*/
internal object MotorTelemetrySnapshotCache {
private val lock = Any()
private var lastRawJson: String? = null
private var lastSample: MotorTelemetrySample? = null
private var lastUpdateMs: Long = 0L
private var lastError: String? = null
private var lastErrorMs: Long = 0L
private var lastCommandResult: String? = null
private var lastCommandAtMs: Long = 0L
private val asyncLock = Any()
private var refreshInFlight: Boolean = false
private var lastRefreshAttemptMs: Long = 0L
private const val REFRESH_MIN_INTERVAL_MS = 500L
private const val REFRESH_STUCK_RESET_MS = 15_000L
fun recordMotorStateJson(json: String, sample: MotorTelemetrySample?) {
synchronized(lock) {
lastRawJson = json
lastSample = sample
lastUpdateMs = System.currentTimeMillis()
lastError = null
lastErrorMs = 0L
}
}
fun recordMotorStateError(reason: String) {
synchronized(lock) {
lastError = reason.ifBlank { "unknown" }
lastErrorMs = System.currentTimeMillis()
}
}
/**
* Phase4B-v11.14: refresh explicit motor runtime-state on a background thread.
*
* Home-card Motor availability must be based on RuntimeHost motor state, not on whether
* the Motor detail page / scatter chart is currently visible. This method never blocks
* the SDK Panel availability loop; if RuntimeHost's motor query stalls after idle, the
* last-known snapshot remains available and the UI stays responsive.
*/
fun requestRuntimeStateRefreshAsync(repo: BridgeRepository, reason: String = "") {
if (!repo.isBound()) return
val now = System.currentTimeMillis()
var shouldLaunch = false
synchronized(asyncLock) {
val stuck = refreshInFlight && lastRefreshAttemptMs > 0L && now - lastRefreshAttemptMs > REFRESH_STUCK_RESET_MS
if (!refreshInFlight || stuck) {
if (now - lastRefreshAttemptMs >= REFRESH_MIN_INTERVAL_MS || stuck) {
refreshInFlight = true
lastRefreshAttemptMs = now
shouldLaunch = true
}
}
}
if (!shouldLaunch) return
Thread({
try {
val json = repo.getLatestMotorStateJson()
val sample = MotorTelemetryParser.parse(json)
recordMotorStateJson(json, sample)
} catch (t: Throwable) {
recordMotorStateError("motor-state-refresh failed; reason=$reason; ${t.message ?: t.javaClass.simpleName}")
} finally {
synchronized(asyncLock) {
refreshInFlight = false
}
}
}, "kiwii-motor-state-refresh-v11.14").apply { isDaemon = true }.start()
}
fun submitForceControlParamsAsync(repo: BridgeRepository, paramsJson: String): String {
val requestId = "sdkpanel-force-${System.currentTimeMillis()}"
Thread({
val started = System.currentTimeMillis()
val result = try {
repo.setMotorForceControlParamsJson(paramsJson)
} catch (t: Throwable) {
JSONObject().put("error", "setMotorForceControlParamsJson async failed: ${t.message ?: t.javaClass.simpleName}").toString()
}
recordCommandResult("setMotorForceControlParamsJson", result)
SessionLogger.log(LogEntry(
timestamp = System.currentTimeMillis(),
component = ComponentType.MOTOR,
action = "setMotorForceControlParamsJson.asyncResult",
params = "requestId=$requestId; elapsedMs=${System.currentTimeMillis() - started}",
result = result
))
requestRuntimeStateRefreshAsync(repo, "after-force-control-command")
}, "kiwii-motor-force-command-v11.14").apply { isDaemon = true }.start()
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-command-queued.v1")
put("uiPath", "ASYNC_COMMAND_NO_UI_TIMEOUT")
put("requestId", requestId)
put("action", "setMotorForceControlParamsJson")
put("queued", true)
val parsedParams = runCatching { JSONObject(paramsJson) }.getOrNull()
if (parsedParams != null) put("params", parsedParams) else put("paramsRaw", paramsJson)
put("note", "Command is sent on a background thread so the Motor UI does not show a false 2000ms timeout after idle. The async result is appended to the Motor Session Log.")
}.toString(2)
}
fun sendMotorHeartbeatAsync(repo: BridgeRepository): String {
val requestId = "sdkpanel-heartbeat-${System.currentTimeMillis()}"
Thread({
val started = System.currentTimeMillis()
val result = try {
repo.sendMotorHeartbeat()
} catch (t: Throwable) {
JSONObject().put("error", "sendMotorHeartbeat async failed: ${t.message ?: t.javaClass.simpleName}").toString()
}
recordCommandResult("sendMotorHeartbeat", result)
SessionLogger.log(LogEntry(
timestamp = System.currentTimeMillis(),
component = ComponentType.MOTOR,
action = "sendMotorHeartbeat.asyncResult",
params = "requestId=$requestId; elapsedMs=${System.currentTimeMillis() - started}",
result = result
))
requestRuntimeStateRefreshAsync(repo, "after-heartbeat")
}, "kiwii-motor-heartbeat-command-v11.14").apply { isDaemon = true }.start()
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-command-queued.v1")
put("uiPath", "ASYNC_COMMAND_NO_UI_TIMEOUT")
put("requestId", requestId)
put("action", "sendMotorHeartbeat")
put("queued", true)
}.toString(2)
}
private fun recordCommandResult(action: String, result: String) {
synchronized(lock) {
lastCommandResult = "$action => ${result.take(500)}"
lastCommandAtMs = System.currentTimeMillis()
}
}
fun homeCardRuntimeStatus(freshTimeoutMs: Long): MotorHomeCardRuntimeStatus {
val snapshot = snapshot()
val raw = snapshot.rawJson
if (raw.isNullOrBlank()) {
return MotorHomeCardRuntimeStatus(
active = false,
contractOk = false,
available = false,
connected = false,
state = "NO_RAW_MOTOR_STATE",
blockedByDongleTransport = false,
blockedByMotorTelemetry = false,
dataAgeMs = Long.MAX_VALUE,
snapshotAgeMs = snapshot.snapshotAgeMs,
reason = snapshot.lastError ?: "NO_MOTOR_RUNTIME_STATE_SNAPSHOT"
)
}
val obj = runCatching { JSONObject(raw) }.getOrNull()
?: return MotorHomeCardRuntimeStatus(
active = false,
contractOk = false,
available = false,
connected = false,
state = "RAW_MOTOR_STATE_NOT_JSON",
blockedByDongleTransport = false,
blockedByMotorTelemetry = false,
dataAgeMs = Long.MAX_VALUE,
snapshotAgeMs = snapshot.snapshotAgeMs,
reason = "RAW_MOTOR_STATE_NOT_JSON"
)
val contractOk = obj.optString("contractVersion", "") == "kiwii.motor-runtime-state.v1"
val available = obj.optBoolean("available", false)
val connected = obj.optBoolean("connected", false) ||
obj.optBoolean("motorConnected", false) ||
obj.optBoolean("online", false)
val state = obj.optString("state", obj.optString("motorState", ""))
val stateLooksConnected = state.equals("CONNECTED", ignoreCase = true) ||
state.equals("ACTIVE", ignoreCase = true) ||
state.equals("RUNNING", ignoreCase = true) ||
state.equals("ONLINE", ignoreCase = true)
val blockedByDongleTransport = obj.optBoolean("blockedByDongleTransport", false)
val blockedByMotorTelemetry = obj.optBoolean("blockedByMotorTelemetry", false)
val rawDataAgeMs = obj.optLong("dataAgeMs", Long.MAX_VALUE)
val dataAgeMs = maxKnownAge(rawDataAgeMs, snapshot.snapshotAgeMs)
val freshData = dataAgeMs in 0..freshTimeoutMs
val active = contractOk &&
!blockedByDongleTransport &&
!blockedByMotorTelemetry &&
(available || connected || stateLooksConnected) &&
freshData
val reason = when {
!contractOk -> "BAD_OR_MISSING_CONTRACT_VERSION"
blockedByDongleTransport -> "BLOCKED_BY_DONGLE_TRANSPORT"
blockedByMotorTelemetry -> "BLOCKED_BY_MOTOR_TELEMETRY"
!(available || connected || stateLooksConnected) -> "MOTOR_RUNTIME_STATE_NOT_CONNECTED"
!freshData -> "MOTOR_RUNTIME_STATE_STALE"
else -> "MOTOR_RUNTIME_STATE_ACTIVE"
}
return MotorHomeCardRuntimeStatus(
active = active,
contractOk = contractOk,
available = available,
connected = connected || stateLooksConnected,
state = state.ifBlank { if (connected) "CONNECTED_FLAG" else "" },
blockedByDongleTransport = blockedByDongleTransport,
blockedByMotorTelemetry = blockedByMotorTelemetry,
dataAgeMs = dataAgeMs,
snapshotAgeMs = snapshot.snapshotAgeMs,
reason = reason
)
}
fun latestMotorStateJsonForUi(): String {
val snapshot = snapshot()
val raw = snapshot.rawJson
return if (raw != null) {
val obj = runCatching { JSONObject(raw) }.getOrNull() ?: unavailableJson("getLatestMotorStateJson", snapshot).apply {
put("rawSnippet", raw.take(240))
put("reason", "LAST_RAW_MOTOR_STATE_NOT_JSON")
}
obj.apply {
put("uiPath", "NO_LIVE_BINDER_CALL")
put("source", "motor-telemetry-last-known")
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("effectiveDataAgeMs", snapshot.sample?.let { maxKnownAge(it.dataAgeMs, snapshot.snapshotAgeMs) } ?: JSONObject.NULL)
put("parsedFreshSample", snapshot.sample != null)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
}.toString(2)
} else {
unavailableJson("getLatestMotorStateJson", snapshot).toString(2)
}
}
fun motorControlStateJsonForUi(): String {
val snapshot = snapshot()
val sample = snapshot.sample
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-control-state.ui.v1")
put("uiPath", "NO_LIVE_BINDER_CALL")
put("source", "motor-telemetry-last-known")
put("available", sample != null)
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
put("lastCommandResult", snapshot.lastCommandResult ?: JSONObject.NULL)
put("lastCommandAgeMs", snapshot.lastCommandAgeMs)
if (sample != null) {
put("forceKg", sample.forceKg)
put("forceN", sample.forceN)
put("ropeLengthM", sample.ropeLengthM)
put("currentA", sample.currentA)
put("voltageV", sample.voltageV)
put("tempC", sample.tempC)
put("dataAgeMs", sample.dataAgeMs)
put("effectiveDataAgeMs", maxKnownAge(sample.dataAgeMs, snapshot.snapshotAgeMs))
put("note", "Control-state live query is intentionally disabled on this UI path because the RuntimeHost query can block after idle. Use RuntimeHost cached telemetry or native logs for raw control internals.")
} else {
put("reason", "NO_FRESH_MOTOR_TELEMETRY_SNAPSHOT")
}
}.toString(2)
}
fun trainingModeQueryForUi(mode: Int): String {
val snapshot = snapshot()
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-training-mode-query.ui.v1")
put("uiPath", "NO_LIVE_BINDER_CALL")
put("requestedMode", mode)
put("source", "motor-telemetry-last-known")
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
put("lastCommandResult", snapshot.lastCommandResult ?: JSONObject.NULL)
put("lastCommandAgeMs", snapshot.lastCommandAgeMs)
put("available", snapshot.rawJson != null || snapshot.sample != null)
put("reason", "Live queryMotorTrainingMode is disabled on SDK Panel button path to avoid 2000ms operation timeout after idle.")
}.toString(2)
}
fun disconnectDiagnosisForUi(): String {
val snapshot = snapshot()
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-disconnect-diagnosis.ui.v1")
put("uiPath", "NO_LIVE_BINDER_CALL")
put("source", "motor-telemetry-last-known")
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
put("hasRawMotorState", snapshot.rawJson != null)
put("hasFreshParsedTelemetry", snapshot.sample != null)
put("diagnosis", when {
snapshot.sample != null -> "FRESH_MOTOR_TELEMETRY_AVAILABLE"
snapshot.rawJson != null -> "RAW_MOTOR_STATE_SEEN_BUT_NOT_FRESH_OR_NOT_PARSEABLE"
snapshot.lastError != null -> "MOTOR_STATE_QUERY_ERROR_OR_TIMEOUT_ON_BACKGROUND_POLL"
else -> "NO_MOTOR_TELEMETRY_SNAPSHOT_YET"
})
put("note", "This is a safe UI diagnosis. It does not perform raw live RuntimeHost motor queries.")
}.toString(2)
}
fun dongleSlotEvidenceJson(maxFreshMs: Long = 5_000L): JSONObject? {
val status = homeCardRuntimeStatus(maxFreshMs)
if (!status.contractOk) return null
val hasConnectionEvidence = status.available || status.connected ||
status.state.equals("CONNECTED", ignoreCase = true) ||
status.state.equals("ACTIVE", ignoreCase = true) ||
status.state.equals("RUNNING", ignoreCase = true) ||
status.state.equals("ONLINE", ignoreCase = true)
if (!hasConnectionEvidence) return null
return JSONObject().apply {
put("slot", 3)
put("state", "CONNECTED")
put("connected", true)
put("device", "MOTOR_POWER")
put("dev", "0x31")
put("source", "motor-runtime-state-last-known")
put("inferred", true)
put("available", status.available)
put("runtimeState", status.state)
put("dataAgeMs", status.dataAgeMs)
put("snapshotAgeMs", status.snapshotAgeMs)
put("stale", !status.active)
put("rawSlotReport", "not-queried")
put("detail", "Connected via explicit motor runtime state; raw dongle slot query skipped")
}
}
fun chartSnapshot(freshTimeoutMs: Long = 2_000L): MotorTelemetryChartSnapshot {
val snapshot = snapshot()
val sample = snapshot.sample
val effectiveAgeMs = sample?.let { maxKnownAge(it.dataAgeMs, snapshot.snapshotAgeMs) } ?: Long.MAX_VALUE
val reason = when {
sample == null && snapshot.rawJson == null -> snapshot.lastError ?: "NO_MOTOR_TELEMETRY_SNAPSHOT_YET"
sample == null -> snapshot.lastError ?: "RAW_MOTOR_STATE_NOT_FRESH_OR_NOT_PARSEABLE"
effectiveAgeMs !in 0..freshTimeoutMs -> "MOTOR_TELEMETRY_STALE"
else -> "MOTOR_TELEMETRY_FRESH"
}
return MotorTelemetryChartSnapshot(
sample = if (effectiveAgeMs in 0..freshTimeoutMs) sample else null,
rawJson = snapshot.rawJson,
effectiveAgeMs = effectiveAgeMs,
snapshotAgeMs = snapshot.snapshotAgeMs,
lastError = snapshot.lastError,
reason = reason
)
}
private fun maxKnownAge(vararg ages: Long): Long {
val known = ages.filter { it >= 0L && it != Long.MAX_VALUE }
return if (known.isEmpty()) Long.MAX_VALUE else known.maxOrNull() ?: Long.MAX_VALUE
}
private fun unavailableJson(action: String, snapshot: Snapshot): JSONObject {
return JSONObject().apply {
put("contractVersion", "kiwii.sdk-panel.motor-safe-ui.v1")
put("action", action)
put("uiPath", "NO_LIVE_BINDER_CALL")
put("available", false)
put("reason", "NO_MOTOR_TELEMETRY_SNAPSHOT_YET")
put("snapshotAgeMs", snapshot.snapshotAgeMs)
put("lastError", snapshot.lastError ?: JSONObject.NULL)
}
}
private fun snapshot(): Snapshot = synchronized(lock) {
val now = System.currentTimeMillis()
Snapshot(
rawJson = lastRawJson,
sample = lastSample,
snapshotAgeMs = if (lastUpdateMs > 0L) now - lastUpdateMs else -1L,
lastError = lastError?.let { err ->
val age = if (lastErrorMs > 0L) now - lastErrorMs else -1L
"$err; errorAgeMs=$age"
},
lastCommandResult = lastCommandResult,
lastCommandAgeMs = if (lastCommandAtMs > 0L) now - lastCommandAtMs else -1L
)
}
private data class Snapshot(
val rawJson: String?,
val sample: MotorTelemetrySample?,
val snapshotAgeMs: Long,
val lastError: String?,
val lastCommandResult: String?,
val lastCommandAgeMs: Long
)
}
@@ -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,89 @@
package com.kiwii.controlpanel.ui.components
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
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 handler = Handler(Looper.getMainLooper())
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"
val params = entry.params?.let { " [$it]" } ?: ""
"$time [$comp] ${entry.action}$params: ${entry.result?.take(120) ?: ""}"
}.ifEmpty { "No logs" }
Log.i(TAG_SESSION, "Phase4B-v7 session log refreshed; entries=${entries.size}")
}
val refreshRunnable = object : Runnable {
override fun run() {
refreshLog()
handler.postDelayed(this, 1000L)
}
}
layout.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
handler.removeCallbacks(refreshRunnable)
handler.post(refreshRunnable)
}
override fun onViewDetachedFromWindow(v: View) {
handler.removeCallbacks(refreshRunnable)
}
})
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
}
companion object {
private const val TAG_SESSION = "KiwiiSDKPanelSession"
}
}
@@ -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(), viewModel)
ComponentType.MOTOR -> MotorOps(requireContext(), viewModel)
ComponentType.TELEMETRY_SAFETY -> TelemetrySafetyOps(requireContext(), viewModel)
ComponentType.SESSION_LOG -> SessionLogOps(requireContext())
}.createView()
binding.opsContainer.removeAllViews()
binding.opsContainer.addView(
opsView,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
}
private fun observeViewModel() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { observeState() }
launch { observeResult() }
launch { observeLogs() }
}
}
}
private suspend fun observeState() {
viewModel.stateSnapshot.collect { snapshot ->
if (snapshot != null) {
latestStateText = JsonFormatter.format(snapshot.data)
val items = 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,170 @@
package com.kiwii.controlpanel.ui.detail
import android.util.Log
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.Dispatchers
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
import kotlinx.coroutines.withContext
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
private const val TAG_OPS = "KiwiiSDKPanelOps"
class ComponentDetailViewModel : ViewModel() {
val bridgeRepo = BridgeRepository()
private val runtimeStateRepo = RuntimeStateRepository(AarProxyStateSource(bridgeRepo))
private val statePoller = StatePoller(runtimeStateRepo, bridgeRepo, SessionLogger)
private val operationExecutor = Executors.newCachedThreadPool()
private val _operationResult = MutableStateFlow<OperationResult<*>?>(null)
val operationResult: StateFlow<OperationResult<*>?> = _operationResult
private val _componentType = MutableStateFlow(ComponentType.RUNTIME_HOST_STATUS)
private val _manualStateSnapshot = MutableStateFlow<Pair<ComponentType, StateSnapshot>?>(null)
val stateSnapshot: StateFlow<StateSnapshot?> = combine(
_componentType,
statePoller.states,
_manualStateSnapshot
) { type, map, manual ->
val polled = map[type]
val manualSnapshot = if (manual?.first == type) manual.second else null
when {
manualSnapshot != null && (polled == null || manualSnapshot.updatedAt >= polled.updatedAt) -> manualSnapshot
else -> polled
}
}.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) {
refreshVisibleLogs()
delay(1000)
}
}
}
fun setComponentType(type: ComponentType) {
_componentType.value = type
statePoller.setFocusedComponent(type)
refreshVisibleLogs()
}
fun <T> executeOperation(
componentType: ComponentType,
actionName: String,
params: String? = null,
block: () -> T
) {
viewModelScope.launch {
val startedAt = System.currentTimeMillis()
Log.i(TAG_OPS, "Phase4B-v11 control click started; component=$componentType; action=$actionName; params=${params ?: ""}")
SessionLogger.log(LogEntry(
timestamp = startedAt,
component = componentType,
action = "TX:$actionName",
params = params,
result = "STARTED"
))
refreshVisibleLogs()
val result = callBlockingWithTimeout(actionName, block)
_operationResult.value = result
val now = System.currentTimeMillis()
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"
}
if (componentType == _componentType.value && result is OperationResult.Success<*> && result.data is String) {
val text = result.data.trim()
if (text.startsWith("{")) {
_manualStateSnapshot.value = componentType to StateSnapshot(
data = result.data,
updatedAt = now,
isDebugChannel = false
)
}
}
val elapsedMs = now - startedAt
Log.i(TAG_OPS, "Phase4B-v11 control click completed; component=$componentType; action=$actionName; elapsedMs=$elapsedMs; resultKind=${result.javaClass.simpleName}")
SessionLogger.log(LogEntry(
timestamp = now,
component = componentType,
action = "RX:$actionName",
params = params,
result = resultStr
))
refreshVisibleLogs()
}
}
private suspend fun <T> callBlockingWithTimeout(
actionName: String,
block: () -> T
): OperationResult<T> = withContext(Dispatchers.IO) {
val future = operationExecutor.submit<OperationResult<T>> {
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)
}
}
try {
future.get(OPERATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
} catch (e: TimeoutException) {
future.cancel(true)
OperationResult.Error(Exception("$actionName timed out after ${OPERATION_TIMEOUT_MS}ms"))
} catch (e: Exception) {
OperationResult.Error(Exception("$actionName failed: ${e.message}", e))
}
}
private fun refreshVisibleLogs() {
_logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value)
}
override fun onCleared() {
super.onCleared()
statePoller.stopPolling()
operationExecutor.shutdownNow()
}
private companion object {
const val OPERATION_TIMEOUT_MS = 2000L
}
}
@@ -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,29 @@
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()
// Phase4B-v10: explicit TX/RX action prefixes must win over result-based inference.
// Otherwise a visible click marker such as action="TX:readDongleState" with
// result="STARTED" is incorrectly rendered as [RX] TX:readDongleState => STARTED.
if (action.startsWith("tx:")) return EntryType.TX
if (action.startsWith("rx:")) return EntryType.RX
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,256 @@
package com.kiwii.controlpanel.ui.detail
import org.json.JSONArray
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 obj = try {
JSONObject(json)
} catch (_: Exception) {
return emptyList()
}
if (looksLikeDongleState(obj)) {
return parseDongleState(obj)
}
val items = mutableListOf<StatItem>()
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", "")
)
is JSONArray -> StatItem(
label = humanize(key),
value = "${raw.length()} items",
detail = raw.toString().compactDetail()
)
else -> StatItem(
label = humanize(key),
value = raw.toString().compactValue()
)
}
items.add(item)
}
return items
}
private fun looksLikeDongleState(obj: JSONObject): Boolean {
val contract = obj.optString("contractVersion", "").lowercase()
if (contract.contains("dongle")) return true
if (obj.has("dongle") && obj.has("handleState")) return true
if (obj.has("transport") && (obj.has("slots") || obj.has("slotStatus") || obj.has("slotStates"))) return true
if (obj.has("transport") && obj.optJSONObject("transport")?.has("usbPresent") == true) return true
return false
}
/** Phase4B-v11.5: Dongle State = operational state first; raw dongle query may be disabled on safe UI path. */
private fun parseDongleState(obj: JSONObject): List<StatItem> {
val items = mutableListOf<StatItem>()
val transport = obj.optJSONObject("transport") ?: obj.optJSONObject("dongle")?.optJSONObject("transport") ?: JSONObject()
val transportState = firstNonBlank(
transport.optString("state", ""),
obj.optString("state", ""),
if (transport.optBooleanFlexible("active") || transport.optBooleanFlexible("connected") || obj.optBooleanFlexible("active")) "ACTIVE" else "UNKNOWN"
)
val usbPresent = transport.optBooleanFlexible("usbPresent")
val bridge = transport.optBooleanFlexible("usbBridgeRegistered")
val readLoop = transport.optBooleanFlexible("readLoopRunning")
items += StatItem(
label = "Transport",
value = transportState.compactValue(),
detail = if (transport.optString("rawDongleQuery", "") == "disabled-on-detail-ui-path") "safe path; raw transport skipped" else "usb=${usbPresent.renderBool("present", "missing")}; bridge=${bridge.renderBool("registered", "no")}; readLoop=${readLoop.renderBool("running", "stopped")}"
)
items += StatItem(
label = "USB",
value = usbPresent.renderBool("PRESENT", "MISSING"),
detail = endpointDetail(transport)
)
items += StatItem(
label = "Bridge",
value = bridge.renderBool("REGISTERED", "NO"),
detail = "RuntimeHost USB bridge"
)
items += StatItem(
label = "Read Loop",
value = readLoop.renderBool("RUNNING", "STOPPED"),
detail = ageDetail(transport)
)
val slotEvidenceNote = firstNonBlank(
obj.optString("slotEvidenceNote", ""),
normalizeLegacySlotDiagnostic(obj.optString("slotDiagnostic", ""))
)
if (slotEvidenceNote.isNotBlank()) {
items += StatItem(
label = "Slot Evidence",
value = "INFERRED",
detail = slotEvidenceNote.compactDetail(72)
)
}
val slots = firstSlotArray(obj)
for (slotIndex in 0..3) {
items += parseSlot(slotIndex, slots)
}
return items
}
private fun firstSlotArray(obj: JSONObject): JSONArray? {
obj.optJSONArray("slots")?.let { return it }
obj.optJSONArray("slotStatus")?.let { return it }
obj.optJSONArray("slotStates")?.let { return it }
obj.optJSONObject("dongle")?.optJSONArray("slots")?.let { return it }
obj.optJSONObject("status")?.optJSONArray("slots")?.let { return it }
return null
}
private fun parseSlot(slotIndex: Int, slots: JSONArray?): StatItem {
val slot = findSlotObject(slotIndex, slots)
if (slot == null) {
return StatItem(
label = "Slot $slotIndex",
value = "UNKNOWN",
detail = "No raw slot report"
)
}
val connected = slot.optBooleanFlexible("connected") || slot.optBooleanFlexible("available") || slot.optBooleanFlexible("active")
val state = firstNonBlank(
slot.optString("state", ""),
slot.optString("status", ""),
if (connected) "CONNECTED" else "DISCONNECTED"
)
val device = firstNonBlank(
slot.optString("device", ""),
slot.optString("dev", ""),
slot.optString("deviceType", ""),
slot.optString("deviceId", "")
)
val seq = firstNonBlank(slot.optString("sequenceId", ""), slot.optString("seq", ""))
val source = slot.optString("source", "")
val rawSlotReport = slot.optString("rawSlotReport", "")
val inferred = slot.optBoolean("inferred", false)
val explicitDetail = slot.optString("detail", "")
val detailParts = mutableListOf<String>()
if (device.isNotBlank()) detailParts += "device=$device"
if (seq.isNotBlank() && seq != "0") detailParts += "seq=$seq"
val age = slot.optLongOrNull("dataAgeMs") ?: slot.optLongOrNull("ageMs")
if (age != null && age >= 0L) detailParts += "age=${age}ms"
if (source.isNotBlank()) detailParts += "source=$source"
if (rawSlotReport.isNotBlank()) detailParts += "rawSlotReport=$rawSlotReport"
if (inferred) detailParts += "inferred=true"
if (explicitDetail.isNotBlank()) detailParts += explicitDetail
return StatItem(
label = "Slot $slotIndex",
value = state.compactValue(),
detail = detailParts.joinToString("; ").ifBlank { "reported" }.compactDetail(72)
)
}
private fun findSlotObject(slotIndex: Int, slots: JSONArray?): JSONObject? {
if (slots == null) return null
for (i in 0 until slots.length()) {
val obj = slots.optJSONObject(i) ?: continue
val reportedIndex = obj.optIntOrNull("slot") ?: obj.optIntOrNull("slotId") ?: obj.optIntOrNull("index")
if (reportedIndex == slotIndex) return obj
}
return slots.optJSONObject(slotIndex)
}
private fun endpointDetail(transport: JSONObject): String {
val epIn = firstNonBlank(transport.optString("epIn", ""), transport.optString("endpointIn", ""))
val epOut = firstNonBlank(transport.optString("epOut", ""), transport.optString("endpointOut", ""))
return when {
epIn.isNotBlank() || epOut.isNotBlank() -> "in=$epIn out=$epOut".trim()
else -> firstNonBlank(transport.optString("device", ""), transport.optString("product", ""), "USB CDC")
}.compactDetail()
}
private fun ageDetail(transport: JSONObject): String {
val inAge = transport.optLongOrNull("lastUsbInAgeMs")
val outAge = transport.optLongOrNull("lastUsbOutAgeMs")
val parts = mutableListOf<String>()
if (inAge != null) parts += "inAge=${inAge}ms"
if (outAge != null) parts += "outAge=${outAge}ms"
return parts.joinToString("; ").ifBlank { "USB read loop state" }
}
private fun firstNonBlank(vararg values: String): String = values.firstOrNull { it.isNotBlank() } ?: ""
private fun normalizeLegacySlotDiagnostic(value: String): String {
if (value.isBlank()) return ""
if (value.contains("Right Handle is connected in handle-state", ignoreCase = true)) {
return "Slot 1 inferred from Right Handle IMU; raw slot not queried."
}
return value
}
private fun Boolean.renderBool(yes: String, no: String): String = if (this) yes else no
private fun JSONObject.optBooleanFlexible(key: String): Boolean {
if (!has(key)) return false
val raw = opt(key) ?: return false
return when (raw) {
is Boolean -> raw
is Number -> raw.toInt() != 0
is String -> raw.equals("true", ignoreCase = true) || raw == "1" || raw.equals("yes", ignoreCase = true) || raw.equals("connected", ignoreCase = true) || raw.equals("active", ignoreCase = true) || raw.equals("running", ignoreCase = true)
else -> false
}
}
private fun JSONObject.optIntOrNull(key: String): Int? {
if (!has(key)) return null
return try { optInt(key) } catch (_: Exception) { null }
}
private fun JSONObject.optLongOrNull(key: String): Long? {
if (!has(key)) return null
return try { optLong(key) } catch (_: Exception) { null }
}
/** 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 }
}
private fun String.compactDetail(maxLen: Int = 48): String {
return replace("\"", "")
.let { if (it.length > maxLen) "${it.take(maxLen - 3)}..." 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")