android: USB层+实时画面+UI骨架(阶段3a)

This commit is contained in:
ZXCLI
2026-09-06 19:16:35 +08:00
parent 5b41a6aa9b
commit 6297a06b97
22 changed files with 1110 additions and 11 deletions
+1
View File
@@ -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)
}
Binary file not shown.
@@ -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")
}
@@ -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 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]
@@ -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>
+1
View File
@@ -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]