android: USB层+实时画面+UI骨架(阶段3a)
This commit is contained in:
@@ -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>
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user