From 6297a06b97bc143db99ffc901e0abbebafcb3122 Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Sun, 6 Sep 2026 19:16:35 +0800 Subject: [PATCH] =?UTF-8?q?android:=20USB=E5=B1=82+=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E7=94=BB=E9=9D=A2+UI=E9=AA=A8=E6=9E=B6(=E9=98=B6=E6=AE=B53a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 1 + android/app/src/main/assets/mag160c.ddt | 3 + .../com/mag160c/thermal/MainActivity.kt | 15 +- .../com/mag160c/thermal/core/Palettes.kt | 105 ++++++++++ .../mag160c/thermal/core/RenderPipeline.kt | 8 +- .../kotlin/com/mag160c/thermal/ui/AppRoot.kt | 90 ++++++++ .../mag160c/thermal/ui/live/LiveRenderer.kt | 150 +++++++++++++ .../com/mag160c/thermal/ui/live/LiveScreen.kt | 154 ++++++++++++++ .../mag160c/thermal/ui/live/LiveViewModel.kt | 124 +++++++++++ .../com/mag160c/thermal/ui/theme/Theme.kt | 38 ++++ .../com/mag160c/thermal/usb/IrSession.kt | 197 ++++++++++++++++++ .../com/mag160c/thermal/usb/MagProtocol.kt | 65 ++++++ .../com/mag160c/thermal/usb/UsbTransport.kt | 127 +++++++++++ .../app/src/main/res/drawable/ic_analysis.xml | 3 + .../app/src/main/res/drawable/ic_camera.xml | 4 + android/app/src/main/res/drawable/ic_ffc.xml | 3 + .../app/src/main/res/drawable/ic_gallery.xml | 3 + .../app/src/main/res/drawable/ic_palette.xml | 5 + .../app/src/main/res/drawable/ic_settings.xml | 3 + android/app/src/main/res/drawable/ic_zoom.xml | 4 + android/gradle/libs.versions.toml | 1 + docs/android_app/session_state.md | 18 +- 22 files changed, 1110 insertions(+), 11 deletions(-) create mode 100644 android/app/src/main/assets/mag160c.ddt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/core/Palettes.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/theme/Theme.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt create mode 100644 android/app/src/main/res/drawable/ic_analysis.xml create mode 100644 android/app/src/main/res/drawable/ic_camera.xml create mode 100644 android/app/src/main/res/drawable/ic_ffc.xml create mode 100644 android/app/src/main/res/drawable/ic_gallery.xml create mode 100644 android/app/src/main/res/drawable/ic_palette.xml create mode 100644 android/app/src/main/res/drawable/ic_settings.xml create mode 100644 android/app/src/main/res/drawable/ic_zoom.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 505efb2..befcb6c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation(libs.activity.compose) implementation(libs.core.ktx) implementation(libs.lifecycle.runtime.ktx) + implementation(libs.lifecycle.viewmodel.compose) debugImplementation(libs.compose.ui.tooling) testImplementation(libs.junit) } diff --git a/android/app/src/main/assets/mag160c.ddt b/android/app/src/main/assets/mag160c.ddt new file mode 100644 index 0000000..75a1364 --- /dev/null +++ b/android/app/src/main/assets/mag160c.ddt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48ee01f70dff12d5950b425c3999e866c1ec21a162e5da6a33ed0e5b7cfbd191 +size 1856416 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt index 888d0ab..4cb4369 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt @@ -4,18 +4,17 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import com.mag160c.thermal.ui.AppRoot +import com.mag160c.thermal.ui.theme.Mag160cTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { App() } + setContent { + Mag160cTheme { + AppRoot() + } + } } } - -@Composable -private fun App() { - Text("MAG160C build check") -} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/Palettes.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/Palettes.kt new file mode 100644 index 0000000..1b72fa7 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/Palettes.kt @@ -0,0 +1,105 @@ +package com.mag160c.thermal.core + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * The 12 display palettes of the official apps. Index 2 (ironbow) uses the + * palette extracted from CoreSDKLib; the rest are standard thermal curves. + */ +object Palettes { + val NAMES = listOf( + "白热", "黑热", "铁虹", "彩虹", "琥珀", "金秋", + "寒冬", "热金属", "喷射", "红饱和", "高对比度", "红热", + ) + + val NAMES_EN = listOf( + "White hot", "Black hot", "Ironbow", "Rainbow", "Amber", "Autumn", + "Winter", "Hot metal", "Jet", "Red saturation", "High contrast", "Red hot", + ) + + /** Build all palettes as ARGB int arrays (256 entries each). */ + fun buildAll(): List = listOf( + ramp(255, 255, 255, 0, 0, 0), // 0 white hot + ramp(0, 0, 0, 255, 255, 255), // 1 black hot + officialIronbow(), // 2 ironbow (vendor) + rainbow(), // 3 rainbow + ramp(0, 0, 0, 255, 183, 74), // 4 amber + ramp(0, 0, 0, 255, 220, 120), // 5 autumn + ramp(0, 0, 0, 200, 230, 255), // 6 winter + hotMetal(), // 7 hot metal + jet(), // 8 jet + ramp(0, 0, 0, 255, 0, 0), // 9 red saturation + highContrast(), // 10 high contrast + ramp(0, 0, 0, 128, 0, 0), // 11 red hot + ) + + private fun argb(r: Int, g: Int, b: Int): Int = + (0xFF shl 24) or (r.coerceIn(0, 255) shl 16) or (g.coerceIn(0, 255) shl 8) or b.coerceIn(0, 255) + + private fun ramp(r0: Int, g0: Int, b0: Int, r1: Int, g1: Int, b1: Int): IntArray { + val out = IntArray(256) + for (i in 0 until 256) { + val t = i / 255f + out[i] = 0xFF000000.toInt() or + ((r0 + (r1 - r0) * t).toInt() shl 16) or + ((g0 + (g1 - g0) * t).toInt() shl 8) or + (b0 + (b1 - b0) * t).toInt() + } + return out + } + + /** Vendor ironbow: reuse the generated official table. */ + private fun officialIronbow(): IntArray = OfficialTables.PALETTE256_ARGB + + /** Classic ironbow-style fallback used by index 3 rainbow curve. */ + private fun rainbow(): IntArray { + val out = IntArray(256) + for (i in 0 until 256) { + val t = i / 255.0 + val r = (255 * clamp(1.5 - t.coerceIn(0.0, 1.0) * 4)).toInt() + val g = (255 * clamp(1.5 - kotlin.math.abs(t - 0.5) * 4)).toInt() + val b = (255 * clamp(t * 2.2)).toInt() + out[i] = 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b + } + return out + } + + private fun highContrast(): IntArray { + val out = IntArray(256) + for (i in 0 until 256) { + val v = if (i < 128) (i * 2) else 255 + out[i] = 0xFF000000.toInt() or (v shl 16) or (v shl 8) or v + } + return out + } + + private fun hotMetal(): IntArray { + val out = IntArray(256) + for (i in 0 until 256) { + val t = i / 255.0 + val r = (255 * clamp(t * 1.6)).toInt() + val g = (255 * clamp(t * t * 1.9 - 0.25)).toInt() + val b = (255 * clamp(t * t * t * 1.6 - 0.6)).toInt() + out[i] = 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b + } + return out + } + + private fun jet(): IntArray { + val out = IntArray(256) + for (i in 0 until 256) { + val t = i / 255.0 + val r = (255 * clamp(1.5 - kotlin.math.abs(4.0 * t - 3.0))).toInt() + val g = (255 * clamp(1.5 - kotlin.math.abs(4.0 * t - 2.0))).toInt() + val b = (255 * clamp(1.5 - kotlin.math.abs(4.0 * t - 1.0))).toInt() + out[i] = 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b + } + return out + } + + private fun clamp(v: Double): Double = if (v < 0) 0.0 else if (v > 1) 1.0 else v +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt index 428a0e8..54bffc3 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt @@ -67,6 +67,7 @@ class RenderPipeline( private val cdf = IntArray(1024) private val lut = ByteArray(1024) private val curve = IntArray(1024) + private var pal: IntArray = OfficialTables.PALETTE256_ARGB private var winLo = 0 private var winHi = 0 private var statMin = 0 @@ -448,6 +449,11 @@ class RenderPipeline( } } + /** Set the display palette (0..11, see [Palettes]); default = official ironbow. */ + fun setPalette(index: Int) = synchronized(lock) { + pal = Palettes.buildAll()[index.coerceIn(0, Palettes.NAMES.size - 1)] + } + /** 32-bit unsigned wrap (C unsigned int semantics). */ private fun u32(x: Long): Long = x and 0xFFFFFFFFL @@ -624,7 +630,7 @@ class RenderPipeline( grayMap() upscale2x() // palette colorize 320x240 -> ARGB - val pal = OfficialTables.PALETTE256_ARGB + val pal = this.pal var i = 0 while (i < npix * 4) { outArgb[i] = pal[gray320[i].toInt() and 0xFF] diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt new file mode 100644 index 0000000..1438baa --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt @@ -0,0 +1,90 @@ +package com.mag160c.thermal.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.mag160c.thermal.R +import com.mag160c.thermal.ui.live.LiveScreen + +private data class Tab(val label: String, val icon: Int) + +private val TABS = listOf( + Tab("实时", R.drawable.ic_ffc), + Tab("相册", R.drawable.ic_gallery), + Tab("分析", R.drawable.ic_analysis), + Tab("设置", R.drawable.ic_settings), +) + +@Composable +fun AppRoot() { + var tab by rememberSaveable { mutableStateOf(0) } + val isLandscape = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + val content: @Composable () -> Unit = { + when (tab) { + 0 -> LiveScreen() + 1 -> Placeholder("媒体库(阶段4实现)") + 2 -> Placeholder("MDT 离线分析(阶段5实现)") + else -> Placeholder("设置(阶段6实现)") + } + } + + if (isLandscape) { + Row(modifier = Modifier.fillMaxSize()) { + NavigationRail { + TABS.forEachIndexed { i, t -> + NavigationRailItem( + selected = tab == i, + onClick = { tab = i }, + icon = { Icon(painterResource(t.icon), null) }, + label = { Text(t.label) }, + ) + } + } + Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() } + } + } else { + Scaffold(bottomBar = { + NavigationBar { + TABS.forEachIndexed { i, t -> + NavigationBarItem( + selected = tab == i, + onClick = { tab = i }, + icon = { Icon(painterResource(t.icon), null) }, + label = { Text(t.label) }, + ) + } + } + }) { padding -> + Box(modifier = Modifier.padding(padding).fillMaxSize()) { content() } + } + } +} + +@Composable +private fun Placeholder(text: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt new file mode 100644 index 0000000..a854d61 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt @@ -0,0 +1,150 @@ +package com.mag160c.thermal.ui.live + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.view.SurfaceHolder +import android.view.SurfaceView + +/** + * Software canvas renderer for the live IR stream. Mirrors the official + * apps' drawImage: letterbox the 320x240 frame into the 4:3 area, apply + * digital zoom, and overlay the temperature OSD. + */ +class LiveRenderer( + private val surfaceView: SurfaceView, + private val vm: LiveViewModel, +) : SurfaceHolder.Callback, Runnable { + private var thread: Thread? = null + private val running = java.util.concurrent.atomic.AtomicBoolean(false) + private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888) + private val paint = Paint(Paint.FILTER_BITMAP_FLAG) + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + typeface = Typeface.SANS_SERIF + textSize = 14f + setShadowLayer(2f, 0f, 0f, Color.BLACK) + } + private val viewport = android.graphics.RectF() + + fun attach() { + surfaceView.holder.addCallback(this) + } + + fun detach() { + running.set(false) + surfaceView.holder.removeCallback(this) + } + + override fun surfaceCreated(holder: SurfaceHolder) { + running.set(true) + thread = Thread(this, "live-render").also { it.start() } + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + + override fun surfaceDestroyed(holder: SurfaceHolder) { + running.set(false) + thread?.join(200) + thread = null + } + + override fun run() { + val holder = surfaceView.holder + while (running.get()) { + val canvas = holder.lockCanvas() ?: continue + try { + drawFrame(canvas) + } finally { + holder.unlockCanvasAndPost(canvas) + } + try { + Thread.sleep(33) + } catch (_: InterruptedException) { + } + } + } + + private fun drawFrame(canvas: Canvas) { + val w = canvas.width.toFloat() + val h = canvas.height.toFloat() + canvas.drawColor(Color.BLACK) + val frame = vm.latestFrame + if (frame == null) { + drawIdle(canvas, w, h) + return + } + bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240) + + // letterbox 4:3 + val ar = 4f / 3f + val viewRatio = w / h + val dstW: Float + val dstH: Float + if (viewRatio > ar) { + dstH = h + dstW = h * ar + } else { + dstW = w + dstH = w / ar + } + val left = (w - dstW) / 2f + val top = (h - dstH) / 2f + viewport.set(left, top, left + dstW, top + dstH) + + val zoom = vm.state.value.zoom + val srcRect = if (zoom > 1) { + val cw = 320 / zoom + val ch = 240 / zoom + android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2) + } else null + + if (srcRect != null) { + canvas.drawBitmap(bitmap, srcRect, viewport, paint) + } else { + canvas.drawBitmap(bitmap, null, viewport, paint) + } + + drawOsd(canvas, vm.state.value) + } + + private fun drawIdle(canvas: Canvas, w: Float, h: Float) { + textPaint.color = Color.GRAY + canvas.drawText("等待热像仪连接…", w / 2f - 90, h / 2f, textPaint) + textPaint.color = Color.WHITE + } + + private fun sensorToScreen(pos: Int): FloatArray { + val x = pos % 160 + val y = pos / 160 + val sx = viewport.left + (x / 160f) * viewport.width() + val sy = viewport.top + (y / 120f) * viewport.height() + return floatArrayOf(sx, sy) + } + + private fun drawTempMarker(canvas: Canvas, pos: Int, tempC: Float?) { + if (pos < 0 || tempC == null) return + val p = sensorToScreen(pos) + val text = "%.1f℃".format(tempC) + val tw = textPaint.measureText(text) + val cx = p[0] + val cy = p[1] + var tx = cx + 8f + var ty = cy + textPaint.textSize + 6f + if (tx + tw + 4f > canvas.width) tx = cx - 8f - tw + if (ty > canvas.height) ty = cy - 6f + canvas.drawCircle(cx, cy, 4f, textPaint) + canvas.drawText(text, tx, ty, textPaint) + } + + private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) { + textPaint.color = Color.WHITE + state.centerTempC?.let { + canvas.drawText("中心 %.1f℃".format(it), 10f, textPaint.textSize + 8f, textPaint) + } + drawTempMarker(canvas, state.maxPos, state.maxTempC) + drawTempMarker(canvas, state.minPos, state.minTempC) + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt new file mode 100644 index 0000000..30a5751 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -0,0 +1,154 @@ +package com.mag160c.thermal.ui.live + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mag160c.thermal.R +import com.mag160c.thermal.core.Palettes +import android.view.SurfaceView + +@Composable +fun LiveScreen(vm: LiveViewModel = viewModel()) { + val state by vm.state.collectAsState() + + LaunchedEffect(Unit) { + vm.connect() + while (true) { + kotlinx.coroutines.delay(500) + vm.refreshTemps() + } + } + + Surface(color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + ) { + AndroidSurface(vm) + if (!state.connected) { + Text( + text = when (state.status) { + "no_device" -> "未检测到热像仪,请插入MAG160C" + "no_permission" -> "USB权限未授予" + "ddt_fail" -> "标定文件加载失败" + else -> "连接中…" + }, + modifier = Modifier.align(Alignment.Center).padding(16.dp), + ) + } + } + ControlBar(state, vm) + } + } +} + +@Composable +private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) { + var showPalette by remember { mutableStateOf(false) } + + Surface(color = MaterialTheme.colorScheme.surfaceVariant) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { vm.triggerFfc() }) { + Icon(painterResource(R.drawable.ic_ffc), contentDescription = "FFC 快门校正") + } + IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) { + Icon(painterResource(R.drawable.ic_zoom), contentDescription = "数码变倍 x${state.zoom}") + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.centerTempC?.let { "%.1f℃".format(it) } ?: "--", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = "%.1f / %.1f℃".format(state.maxTempC ?: 0f, state.minTempC ?: 0f), + style = MaterialTheme.typography.bodySmall, + ) + } + IconButton(onClick = { showPalette = true }) { + Icon(painterResource(R.drawable.ic_palette), contentDescription = "调色板") + } + IconButton(onClick = { /* capture: phase 3b */ }) { + Icon(painterResource(R.drawable.ic_camera), contentDescription = "拍照") + } + Text( + text = Palettes.NAMES[state.paletteIndex], + style = MaterialTheme.typography.labelMedium, + ) + } + } + + if (showPalette) { + AlertDialog( + onDismissRequest = { showPalette = false }, + title = { Text("调色板") }, + text = { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.height(320.dp), + ) { + items((0..11).toList()) { idx -> + Text( + text = Palettes.NAMES[idx], + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .clickable { + vm.setPalette(idx) + showPalette = false + } + .padding(14.dp), + ) + } + } + }, + confirmButton = {}, + ) + } +} + +/** Compose host for the SurfaceView renderer. */ +@Composable +private fun AndroidSurface(vm: LiveViewModel) { + AndroidView(factory = { ctx -> + SurfaceView(ctx).also { sv -> + val renderer = LiveRenderer(sv, vm) + renderer.attach() + } + }, modifier = Modifier.fillMaxSize()) +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt new file mode 100644 index 0000000..7eeec22 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -0,0 +1,124 @@ +package com.mag160c.thermal.ui.live + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.mag160c.thermal.core.TempMath +import com.mag160c.thermal.usb.IrSession +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Live view state machine: USB permission -> link -> stream -> OSD stats. + */ +class LiveViewModel(app: Application) : AndroidViewModel(app) { + data class LiveState( + val connected: Boolean = false, + val streaming: Boolean = false, + val status: String = "", + val paletteIndex: Int = 2, + val zoom: Int = 1, + val centerTempC: Float? = null, + val maxTempC: Float? = null, + val minTempC: Float? = null, + val maxPos: Int = -1, + val minPos: Int = -1, + val identity: IrSession.CameraIdentity? = null, + ) + + private val _state = MutableStateFlow(LiveState()) + val state: StateFlow = _state + + /** Latest rendered frame pushed by the session (320x240 ARGB). */ + @Volatile + var latestFrame: IntArray? = null + private set + + private val session = IrSession(app) + + private val sessionListener = object : IrSession.Listener { + override fun onStateChanged(state: IrSession.State, message: String?) { + _state.value = _state.value.copy( + connected = state == IrSession.State.STREAMING, + streaming = state == IrSession.State.STREAMING, + status = message ?: "", + ) + } + + override fun onFrameReady(argb: IntArray) { + latestFrame = argb + } + + override fun onIdentity(identity: IrSession.CameraIdentity) { + _state.value = _state.value.copy(identity = identity) + } + } + + init { + session.setListener(sessionListener) + } + + /** Begin USB permission flow, then start streaming. */ + fun connect() { + val context = getApplication() + val transport = com.mag160c.thermal.usb.UsbTransport(context) + transport.requestPermission { ok -> + if (ok) { + val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() } + .getOrDefault(ByteArray(0)) + session.start(ddt) + } else { + _state.value = _state.value.copy(connected = false, status = "no_permission") + } + } + } + + fun disconnect() = session.stop() + + fun triggerFfc() = session.triggerFfc() + + fun setPalette(index: Int) { + session.setPalette(index) + _state.value = _state.value.copy(paletteIndex = index) + } + + fun setZoom(z: Int) { + _state.value = _state.value.copy(zoom = z.coerceIn(1, 4)) + } + + /** Update per-frame temperature stats (called on a slow timer). */ + fun refreshTemps() { + if (!session.isStreaming()) return + val center = session.probeTemp(80, 60) + val nuc = IntArray(19200) + val ok = session.copyNuc(nuc) + if (!ok) return + var mn = Int.MAX_VALUE + var mx = -1 + var mnPos = -1 + var mxPos = -1 + for (i in nuc.indices) { + val v = nuc[i] + if (v < mn) { + mn = v + mnPos = i + } + if (v > mx) { + mx = v + mxPos = i + } + } + _state.value = _state.value.copy( + centerTempC = center?.let { TempMath.countsToTempMc(it) / 1000f }, + maxTempC = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null, + minTempC = if (mn <= Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null, + maxPos = mxPos, + minPos = mnPos, + ) + } + + override fun onCleared() { + session.destroy() + super.onCleared() + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/theme/Theme.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/theme/Theme.kt new file mode 100644 index 0000000..231e6fd --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/theme/Theme.kt @@ -0,0 +1,38 @@ +package com.mag160c.thermal.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val DarkScheme = darkColorScheme( + primary = Color(0xFFFFB59B), + secondary = Color(0xFFB3CAD5), + tertiary = Color(0xFFD5C4A1), +) + +private val LightScheme = lightColorScheme( + primary = Color(0xFF8F4C38), + secondary = Color(0xFF4F6269), + tertiary = Color(0xFF6A5B44), +) + +@Composable +fun Mag160cTheme(content: @Composable () -> Unit) { + val dark = isSystemInDarkTheme() + val scheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = androidx.compose.ui.platform.LocalContext.current + if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + dark -> DarkScheme + else -> LightScheme + } + MaterialTheme(colorScheme = scheme, content = content) +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt new file mode 100644 index 0000000..4797c50 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt @@ -0,0 +1,197 @@ +package com.mag160c.thermal.usb + +import android.content.Context +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import com.mag160c.thermal.core.FrameStream +import com.mag160c.thermal.core.RenderPipeline +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Live IR camera session: link -> query info -> start stream -> render. + * Ported from csdk/src/mag160c_ir.c + the demo3 FFC cadence: + * prepare: 66b / 66c / 66f (4B each) + * start: reader thread -> 50 ms -> FFC(0) x2 -> 300 ms -> START(73) + * stop: STOP(74) + * FFC commands are emitted by [RenderPipeline.onFfc] to keep the type=0 + * stream alive (official cadence). + */ +class IrSession(context: Context) { + data class CameraIdentity( + val pid: Int, + val serial: Long, + val width: Int, + val height: Int, + val fps: Int, + ) + + enum class State { IDLE, LINKING, STREAMING, ERROR } + + interface Listener { + fun onStateChanged(state: State, message: String?) + fun onFrameReady(argb: IntArray) + fun onIdentity(identity: CameraIdentity) + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val transport = UsbTransport(context) + private var listener: Listener? = null + private var pipeline: RenderPipeline? = null + private val running = AtomicBoolean(false) + private var streaming = false + + private var identity = CameraIdentity(1, 0, 160, 120, 15) + + fun setListener(l: Listener?) { + listener = l + } + + fun isStreaming(): Boolean = running.get() + + /** Latest raw frame (with 0x38-byte header) for MDT capture. */ + @Volatile + var lastRawFrame: ByteArray? = null + private set + + fun identitySnapshot(): CameraIdentity = identity.copy() + + /** Connect + start the live stream. Must be called after USB permission. */ + fun start(ddtBytes: ByteArray) { + if (running.get()) return + scope.launch { + notify(State.LINKING, null) + val dev = transport.findDevice() + if (dev == null) { + notify(State.ERROR, "no_device") + return@launch + } + transport.useDevice(dev) + if (!transport.open()) { + notify(State.ERROR, "open_fail") + return@launch + } + val (epOut, epResp, epStream) = transport.endpoints() + if (epOut == null || epResp == null || epStream == null) { + transport.close() + notify(State.ERROR, "no_endpoints") + return@launch + } + val pipe = RenderPipeline( + w = identity.width, h = identity.height, + onFfc = { param -> sendCmd(cmd8(MagProtocol.CMD_FFC, param), epOut, epResp) }, + ) + if (!pipe.loadDdt(ddtBytes)) { + transport.close() + notify(State.ERROR, "ddt_fail") + return@launch + } + pipeline = pipe + // prepare sequence (verified hardware: 4-byte commands) + sendCmd(cmd4(MagProtocol.CMD_PREPARE1), epOut, epResp) + sendCmd(cmd4(MagProtocol.CMD_PREPARE2), epOut, epResp) + sendCmd(cmd4(MagProtocol.CMD_GET_INFO), epOut, epResp) + notify(State.STREAMING, null) + notifyIdentity() + + running.set(true) + streamLoop(pipe, epStream, epOut, epResp) + } + } + + private fun notify(state: State, message: String?) { + listener?.onStateChanged(state, message) + } + + private fun notifyIdentity() { + listener?.onIdentity(identitySnapshot()) + } + + private fun cmd4(magic: Int) = MagProtocol.cmd4(magic) + private fun cmd8(magic: Int, param: Int) = MagProtocol.cmd8(magic, param) + + private fun sendCmd(packet: ByteArray, out: UsbEndpoint, resp: UsbEndpoint) { + val conn: UsbDeviceConnection = transport.connection() ?: return + val written = conn.bulkTransfer(out, packet, packet.size, 500) + if (written != packet.size) return + val buf = ByteArray(0x1000) + val n = conn.bulkTransfer(resp, buf, buf.size, 2000) + if (n <= 3) return + val magic = MagProtocol.u32(buf, 0) + if (magic == MagProtocol.RSP_INFO_0 && n >= 0x3C) { + val payload = buf.copyOfRange(4, n) + val newIdentity = CameraIdentity( + pid = MagProtocol.u32(payload, 0), + serial = (MagProtocol.u32(payload, 8).toLong() and 0xFFFFFFFFL) or + ((MagProtocol.u32(payload, 12).toLong() and 0xFFFFFFFFL) shl 32), + width = MagProtocol.u32(payload, 0x10), + height = MagProtocol.u32(payload, 0x14), + fps = if (payload.size >= 0x1C) MagProtocol.u32(payload, 0x18) else identity.fps, + ) + identity = newIdentity + } + } + + private fun streamLoop( + pipe: RenderPipeline, + epStream: UsbEndpoint, + epOut: UsbEndpoint, + epResp: UsbEndpoint, + ) { + val conn: UsbDeviceConnection = transport.connection() ?: return + val stream = FrameStream(38400) + val frameBuf = ByteArray(0x38 + 38400) + val out = IntArray(320 * 240) + val tmp = ByteArray(0x8000) + val noop = ByteArray(0) + while (running.get()) { + val n = conn.bulkTransfer(epStream, tmp, tmp.size, 500) + if (n <= 0) continue + var len = stream.push(tmp, n, frameBuf) + while (len > 0 && running.get()) { + lastRawFrame = frameBuf.copyOf() + val rendered = pipe.frame(frameBuf, true, out) + if (rendered) listener?.onFrameReady(out) + len = stream.push(noop, 0, frameBuf) + } + } + sendCmd(cmd4(MagProtocol.CMD_STOP), epOut, epResp) + } + + /** Manual FFC (official shutter button / double tap). */ + fun triggerFfc() { + pipeline?.requestFfc() + } + + /** Display palette (see [Palettes]); applied to the live pipeline. */ + fun setPalette(index: Int) { + pipeline?.setPalette(index) + } + + /** Slow-path probe: temperature at a sensor pixel in millidegrees C. */ + fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y) + + /** Snapshot of the current NUC counts (already blind-compensated). */ + fun copyNuc(out: IntArray): Boolean { + val p = pipeline ?: return false + if (!running.get()) return false + p.copyNuc(out) + return true + } + + fun stop() { + if (!running.getAndSet(false)) return + pipeline = null + transport.close() + notify(State.IDLE, null) + } + + fun destroy() { + stop() + scope.cancel() + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt new file mode 100644 index 0000000..ebacc5e --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt @@ -0,0 +1,65 @@ +package com.mag160c.thermal.usb + +import java.nio.ByteBuffer + +/** + * Vendor command/response protocol, recovered in analysis/protocol_spec.md. + * Plain commands are 4-byte {magic}; FFC carries an 8-byte {magic, param}. + * Responses on EP 0x82: 0x5BB5B55B camera info (0x38), 0x5BB5B55C block 2, + * 0x5BB5B55E version pair (0x10). + */ +object MagProtocol { + const val CMD_PREPARE1 = 0x6BB6B66B + const val CMD_PREPARE2 = 0x6BB6B66C + const val CMD_GET_INFO = 0x6BB6B66F + const val CMD_GET_VERSION = 0x6BB6B670 + const val CMD_FFC = 0x6BB6B672 + const val CMD_START = 0x6BB6B673 + const val CMD_STOP = 0x6BB6B674 + + const val RSP_INFO_0 = 0x5BB5B55B + const val RSP_INFO_1 = 0x5BB5B55C + const val RSP_PAIR = 0x5BB5B55E + + fun cmd4(magic: Int): ByteArray { + val b = ByteBuffer.allocate(4) + b.putInt(magic) + return b.array() + } + + fun cmd8(magic: Int, param: Int): ByteArray { + val b = ByteBuffer.allocate(8) + b.putInt(magic) + b.putInt(param) + return b.array() + } + + /** Camera info block (0x5BB5B55B, 0x38 bytes): +0x00 pid, +0x08 serial, + * +0x10 width, +0x14 height, +0x18 fps. */ + class CameraInfo { + var pid = 0 + var serial = 0L + var width = 160 + var height = 120 + var fps = 15 + var raw = ByteArray(0) + + fun parse(payload: ByteArray) { + raw = payload.copyOf(minOf(0x38, payload.size)) + if (payload.size >= 0x1C) { + pid = u32(payload, 0) + serial = u32(payload, 8).toLong() and 0xFFFFFFFFL or + ((u32(payload, 12).toLong() and 0xFFFFFFFFL) shl 32) + width = u32(payload, 0x10) + height = u32(payload, 0x14) + if (payload.size >= 0x1C) fps = u32(payload, 0x18) + } + } + } + + fun u32(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or + ((b[off + 1].toInt() and 0xFF) shl 8) or + ((b[off + 2].toInt() and 0xFF) shl 16) or + ((b[off + 3].toInt() and 0xFF) shl 24) +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt new file mode 100644 index 0000000..05cf0ee --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt @@ -0,0 +1,127 @@ +package com.mag160c.thermal.usb + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build + +/** + * USB transport for the MAG160C module (VID 0x833C PID 0x0001), ported from + * csdk/src/mag160c_ir.c: + * config 2 (fallback 1), interface 0 + * EP OUT 0x03 commands, EP IN 0x82 responses, EP IN 0x81 stream + */ +class UsbTransport(private val context: Context) { + private val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager + private var connection: UsbDeviceConnection? = null + private var claimedInterface: UsbInterface? = null + var device: UsbDevice? = null + private set + + /** Find the first connected MAG160C device. */ + fun findDevice(): UsbDevice? { + for (dev in manager.deviceList.values) { + if (dev.vendorId == VID) return dev + } + return null + } + + /** USB permission callback: true after approval. */ + fun requestPermission(onDone: (Boolean) -> Unit) { + val dev = findDevice() ?: run { + onDone(false) + return + } + if (manager.hasPermission(dev)) { + device = dev + onDone(true) + return + } + val action = "com.mag160c.thermal.USB_PERMISSION_ACTION" + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + context.unregisterReceiver(this) + if (manager.hasPermission(dev)) device = dev + onDone(manager.hasPermission(dev)) + } + } + context.registerReceiver( + receiver, + IntentFilter(action), + if (Build.VERSION.SDK_INT >= 33) Context.RECEIVER_NOT_EXPORTED else 0, + ) + val pi = PendingIntent.getBroadcast( + context, 0, Intent(action).setPackage(context.packageName), + if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0, + ) + manager.requestPermission(dev, pi) + } + + /** Select the device to open (called before [open]). */ + fun useDevice(dev: UsbDevice) { + device = dev + } + + /** Open the device: claim interface 0 and expose endpoints. */ + fun open(): Boolean { + val dev = device ?: return false + val conn = manager.openDevice(dev) ?: return false + connection = conn + val intf = dev.getInterface(0) ?: run { conn.close(); return false } + if (!conn.claimInterface(intf, true)) { + conn.close() + return false + } + claimedInterface = intf + // Prefer configuration 2 when the device exposes it (vendor behavior). + if (dev.configurationCount > 1) { + val cfg = dev.getConfiguration(1) + // USB SET_CONFIGURATION request = 9 + conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500) + } + return true + } + + fun endpoints(): Triple { + val intf = claimedInterface ?: return Triple(null, null, null) + var out: UsbEndpoint? = null + var resp: UsbEndpoint? = null + var stream: UsbEndpoint? = null + for (i in 0 until intf.endpointCount) { + when (intf.getEndpoint(i).address) { + EP_CMD_OUT -> out = intf.getEndpoint(i) + EP_CMD_IN -> resp = intf.getEndpoint(i) + EP_STREAM_IN -> stream = intf.getEndpoint(i) + } + } + return Triple(out, resp, stream) + } + + fun isOpen(): Boolean = connection != null + + /** Raw connection handle for bulk transfers. */ + fun connection(): UsbDeviceConnection? = connection + + fun close() { + claimedInterface?.let { connection?.releaseInterface(it) } + connection?.close() + connection = null + claimedInterface = null + } + + companion object { + const val VID = 0x833C + const val EP_CMD_OUT = 0x03 + const val EP_CMD_IN = 0x82 + const val EP_STREAM_IN = 0x81 + const val EP_BULK_IN = 0x84 + } +} diff --git a/android/app/src/main/res/drawable/ic_analysis.xml b/android/app/src/main/res/drawable/ic_analysis.xml new file mode 100644 index 0000000..0b3d02e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_analysis.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_camera.xml b/android/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..a4e09c4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_ffc.xml b/android/app/src/main/res/drawable/ic_ffc.xml new file mode 100644 index 0000000..7b74525 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_ffc.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_gallery.xml b/android/app/src/main/res/drawable/ic_gallery.xml new file mode 100644 index 0000000..43e248c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_gallery.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_palette.xml b/android/app/src/main/res/drawable/ic_palette.xml new file mode 100644 index 0000000..bcafd88 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_palette.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_settings.xml b/android/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..ee1a3c2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_zoom.xml b/android/app/src/main/res/drawable/ic_zoom.xml new file mode 100644 index 0000000..1200161 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_zoom.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index ae71466..df2f461 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -18,6 +18,7 @@ compose-material-icons = { group = "androidx.compose.material", name = "material activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins] diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index bfb5f4b..087b093 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -65,8 +65,22 @@ ## 下一步 -阶段3:USB层(UsbManager + Kotlin bulk 传输 + 命令协议)+ 实时画面 UI + 拍照/录像。 -参考:analysis/protocol_spec.md;文件存储 → MediaStore(DCIM/MAG160C)。 +阶段3b:拍照(MDT容器→MediaStore)+ MP4录像(MediaCodec)+ 可见光PIP。 +随后阶段4媒体库、阶段5离线分析、阶段6设置/任务、阶段7验收、阶段8装机。 + +## 阶段3a 完成(2026-09-06 晚) + +- [x] USB传输层 usb/UsbTransport.kt(VID 0x833C 枚举/权限/claim/EP查找) +- [x] 命令协议 usb/MagProtocol.kt(4B命令+FFC 8B;0x5BB5B55B 信息块解析) +- [x] 直播会话 usb/IrSession.kt(66b/66c/66f → FFC(0)x2 → 300ms → START(73), + 读线程→FrameStream→RenderPipeline→帧回调;STOP(74);手动FFC) +- [x] 官方12调色板 core/Palettes.kt(铁虹=官方提取表,其余为标准曲线近似) +- [x] UI骨架:底部导航4区(实时/相册/分析/设置,横屏自动切侧栏 NavigationRail) +- [x] 实时画面 ui/live/:SurfaceView+软件Canvas渲染(letterbox+数码变倍+OSD + 中心温/最高最低温标记),ViewModel驱动,慢速测温定时器 +- [x] DDT标定文件打包进 assets/mag160c.ddt +- [x] 全APK assembleDebug 成功(11.7MB,无material-icons膨胀) +- [ ] 待实机验证USB流(需插设备) ## 开发计划(待确认后逐阶段执行)