android: USB层+实时画面+UI骨架(阶段3a)
This commit is contained in:
@@ -47,6 +47,7 @@ dependencies {
|
|||||||
implementation(libs.activity.compose)
|
implementation(libs.activity.compose)
|
||||||
implementation(libs.core.ktx)
|
implementation(libs.core.ktx)
|
||||||
implementation(libs.lifecycle.runtime.ktx)
|
implementation(libs.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -4,18 +4,17 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.material3.Text
|
import com.mag160c.thermal.ui.AppRoot
|
||||||
import androidx.compose.runtime.Composable
|
import com.mag160c.thermal.ui.theme.Mag160cTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent { App() }
|
setContent {
|
||||||
|
Mag160cTheme {
|
||||||
|
AppRoot()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun App() {
|
|
||||||
Text("MAG160C build check")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<IntArray> = 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
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ class RenderPipeline(
|
|||||||
private val cdf = IntArray(1024)
|
private val cdf = IntArray(1024)
|
||||||
private val lut = ByteArray(1024)
|
private val lut = ByteArray(1024)
|
||||||
private val curve = IntArray(1024)
|
private val curve = IntArray(1024)
|
||||||
|
private var pal: IntArray = OfficialTables.PALETTE256_ARGB
|
||||||
private var winLo = 0
|
private var winLo = 0
|
||||||
private var winHi = 0
|
private var winHi = 0
|
||||||
private var statMin = 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). */
|
/** 32-bit unsigned wrap (C unsigned int semantics). */
|
||||||
private fun u32(x: Long): Long = x and 0xFFFFFFFFL
|
private fun u32(x: Long): Long = x and 0xFFFFFFFFL
|
||||||
|
|
||||||
@@ -624,7 +630,7 @@ class RenderPipeline(
|
|||||||
grayMap()
|
grayMap()
|
||||||
upscale2x()
|
upscale2x()
|
||||||
// palette colorize 320x240 -> ARGB
|
// palette colorize 320x240 -> ARGB
|
||||||
val pal = OfficialTables.PALETTE256_ARGB
|
val pal = this.pal
|
||||||
var i = 0
|
var i = 0
|
||||||
while (i < npix * 4) {
|
while (i < npix * 4) {
|
||||||
outArgb[i] = pal[gray320[i].toInt() and 0xFF]
|
outArgb[i] = pal[gray320[i].toInt() and 0xFF]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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<LiveState> = _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<Application>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<UsbEndpoint?, UsbEndpoint?, UsbEndpoint?> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M19,3H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM9,17H7v-7h2V17zM13,17h-2V7h2V17zM17,17h-2v-4h2V17z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M12,12m-3.2,0a3.2,3.2 0 1,1 6.4,0a3.2,3.2 0 1,1 -6.4,0" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5s5,2.24 5,5S14.76,17 12,17z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M12,4a8,8 0 1,0 0,16a8,8 0 1,0 0,-16zM12,2a10,10 0 1,1 0,20a10,10 0 1,1 0,-20zM12,8a4,4 0 1,0 0,8a4,4 0 1,0 0,-8zM11,0h2v4h-2zM11,20h2v4h-2zM2,11h4v2h-4zM20,11h4v2h-4z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3,-4.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M12,2C6.49,2 2,6.49 2,8c0,0 0,10 0,10c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8C22,2 17.51,2 12,2zM20,18H4V8h16V18z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M6,11h2v6H6zM10,11h2v6h-2zM10,15h2v3h-2z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M18,13a2,2 0 1,0 0.001,0z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.7,4.96c-0.22,-0.16 -0.47,-0.06 -0.59,0.22L3.2,8.28c-0.12,0.21 -0.08,0.47 0.12,0.61l2.03,1.58C5.14,9.9 5,10.44 5,11s0.02,0.61 0.07,0.94L2.08,13.52c-0.18,0.14 -0.24,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.03,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.03,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.01L19.14,12.01zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6S10.02,8.4 12,8.4s3.6,1.6 3.6,3.6S13.98,15.6 12,15.6z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M15,3l2.3,2.3l-2.89,2.87l1.42,1.42L18.7,6.7L21,9V2h-7zM3,9V2h7l-2.3,2.3l2.87,2.89l-1.42,1.42L9.3,6.31L7,8.6l-1.99,-2zM9,15l-2.3,2.3l-1.42,-1.42L6.3,15l-2.87,-2.89l-1.42,1.42l2.89,2.87L3,18l7,7v-7h-7zM21,18l-2.3,-2.3l1.42,-1.42L21.3,17l-2.87,2.87l1.42,1.42L21.3,21l2.7,3V21h-7z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M4,4l4,0l0,4l-4,0z M16,4l4,0l0,4l-4,0z M4,16l4,0l0,4l-4,0z M16,16l4,0l0,4l-4,0z" />
|
||||||
|
</vector>
|
||||||
@@ -18,6 +18,7 @@ compose-material-icons = { group = "androidx.compose.material", name = "material
|
|||||||
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
@@ -65,8 +65,22 @@
|
|||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
阶段3:USB层(UsbManager + Kotlin bulk 传输 + 命令协议)+ 实时画面 UI + 拍照/录像。
|
阶段3b:拍照(MDT容器→MediaStore)+ MP4录像(MediaCodec)+ 可见光PIP。
|
||||||
参考:analysis/protocol_spec.md;文件存储 → MediaStore(DCIM/MAG160C)。
|
随后阶段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流(需插设备)
|
||||||
|
|
||||||
## 开发计划(待确认后逐阶段执行)
|
## 开发计划(待确认后逐阶段执行)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user