android: fix black screen (missing FFC(0)x2+START pre-stream sequence) + field debug log to DCIM/MAG160C
This commit is contained in:
@@ -10,6 +10,8 @@ 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)
|
||||||
|
// field-debug crash hook + logcat/file logger (file opens on connect)
|
||||||
|
com.mag160c.thermal.media.DebugLog.init(applicationContext)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
// immersive: hide the status bar (swipe to reveal)
|
// immersive: hide the status bar (swipe to reveal)
|
||||||
window.insetsController?.let { c ->
|
window.insetsController?.let { c ->
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.mag160c.thermal.media
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field-debug logger: mirrors every line to logcat AND appends it to a
|
||||||
|
* timestamped text file in the public DCIM/MAG160C album folder, so the user
|
||||||
|
* can hand over the file without adb (real-device bring-up, round 11).
|
||||||
|
*
|
||||||
|
* Lines are flushed per write; the stream stays open for the session and is
|
||||||
|
* closed by [closeFile] (or on process death — at most the very last line is
|
||||||
|
* lost). A crash hook writes the stack trace before rethrowing to the
|
||||||
|
* previous handler.
|
||||||
|
*/
|
||||||
|
object DebugLog {
|
||||||
|
private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.ENGLISH)
|
||||||
|
private val fileFmt = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH)
|
||||||
|
private val lock = Any()
|
||||||
|
private var stream: OutputStream? = null
|
||||||
|
private var outUri: Uri? = null
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var previousHandler: Thread.UncaughtExceptionHandler? = null
|
||||||
|
private var bytesWritten = 0L
|
||||||
|
|
||||||
|
/** Install once from MainActivity.onCreate: crash hook + app context. */
|
||||||
|
fun init(context: Context) {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (appContext != null) return
|
||||||
|
appContext = context.applicationContext
|
||||||
|
previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { t, e ->
|
||||||
|
log("crash", "thread=$t ${Log.getStackTraceString(e)}")
|
||||||
|
closeFile()
|
||||||
|
previousHandler?.uncaughtException(t, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log one line (also to logcat as MAG160C/<tag>). */
|
||||||
|
fun log(tag: String, msg: String) {
|
||||||
|
Log.d("MAG160C/$tag", msg)
|
||||||
|
val line = "${fmt.format(Date())} [$tag] $msg\n"
|
||||||
|
synchronized(lock) {
|
||||||
|
val s = stream
|
||||||
|
if (s == null) {
|
||||||
|
Log.v("MAG160C/$tag", "(no file yet) $msg")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
s.write(line.toByteArray(Charsets.UTF_8))
|
||||||
|
s.flush()
|
||||||
|
bytesWritten += line.length
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("MAG160C/$tag", "log write failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a fresh debug_*.log in DCIM/MAG160C (API 29+, MediaStore, no
|
||||||
|
* permission needed for own files) or the app-specific dir below Q.
|
||||||
|
* Closes any previous file. Returns the display path for logging.
|
||||||
|
*/
|
||||||
|
fun startFile(context: Context): String {
|
||||||
|
val name = "debug_${fileFmt.format(Date())}.log"
|
||||||
|
closeFile()
|
||||||
|
synchronized(lock) {
|
||||||
|
bytesWritten = 0
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, "text/plain")
|
||||||
|
put(
|
||||||
|
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||||
|
Environment.DIRECTORY_DCIM + "/MAG160C",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val uri = ctx.contentResolver.insert(
|
||||||
|
MediaStore.Files.getContentUri("external"),
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
if (uri == null) {
|
||||||
|
Log.w("MAG160C/log", "MediaStore insert failed")
|
||||||
|
return "(insert failed)"
|
||||||
|
}
|
||||||
|
outUri = uri
|
||||||
|
stream = ctx.contentResolver.openOutputStream(uri)
|
||||||
|
} else {
|
||||||
|
val dir = File(ctx.getExternalFilesDir(null), "MAG160C").apply { mkdirs() }
|
||||||
|
val f = File(dir, name)
|
||||||
|
outUri = Uri.fromFile(f)
|
||||||
|
stream = FileOutputStream(f)
|
||||||
|
}
|
||||||
|
val header = "MAG160C debug ${fileFmt.format(Date())} " +
|
||||||
|
"sdk=${Build.VERSION.SDK_INT} dev=${Build.MANUFACTURER} ${Build.MODEL}\n"
|
||||||
|
stream?.write(header.toByteArray(Charsets.UTF_8))
|
||||||
|
stream?.flush()
|
||||||
|
}
|
||||||
|
return "DCIM/MAG160C/$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush + close the current file (kept visible in the gallery). */
|
||||||
|
fun closeFile() {
|
||||||
|
synchronized(lock) {
|
||||||
|
try {
|
||||||
|
stream?.flush()
|
||||||
|
stream?.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
stream = null
|
||||||
|
outUri = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -293,7 +293,14 @@ private fun statusText(state: LiveViewModel.LiveState): String = when (state.sta
|
|||||||
"no_device" -> "未检测到热像仪,请插入MAG160C"
|
"no_device" -> "未检测到热像仪,请插入MAG160C"
|
||||||
"no_permission" -> "USB权限未授予"
|
"no_permission" -> "USB权限未授予"
|
||||||
"ddt_fail" -> "标定文件加载失败"
|
"ddt_fail" -> "标定文件加载失败"
|
||||||
else -> "连接中…"
|
"open_fail" -> "USB打开失败(查看调试日志)"
|
||||||
|
"no_endpoints" -> "未找到数据端点(查看调试日志)"
|
||||||
|
else -> {
|
||||||
|
val prefix = if (state.status.startsWith("exception:")) {
|
||||||
|
"异常:" + state.status.removePrefix("exception:")
|
||||||
|
} else "连接中…"
|
||||||
|
if (prefix.length > 30) prefix.take(30) + "…" else prefix
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
private val sessionListener = object : IrSession.Listener {
|
private val sessionListener = object : IrSession.Listener {
|
||||||
override fun onStateChanged(state: IrSession.State, message: String?) {
|
override fun onStateChanged(state: IrSession.State, message: String?) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("vm", "session state $state msg=$message")
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
connected = state == IrSession.State.STREAMING,
|
connected = state == IrSession.State.STREAMING,
|
||||||
streaming = state == IrSession.State.STREAMING,
|
streaming = state == IrSession.State.STREAMING,
|
||||||
@@ -60,6 +61,10 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onFrameReady(argb: IntArray) {
|
override fun onFrameReady(argb: IntArray) {
|
||||||
|
if (framesRcvd == 0) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("vm", "first rendered frame -> UI")
|
||||||
|
}
|
||||||
|
framesRcvd++
|
||||||
latestFrame = argb
|
latestFrame = argb
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +73,9 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var framesRcvd = 0
|
||||||
|
|
||||||
init {
|
init {
|
||||||
session.setListener(sessionListener)
|
session.setListener(sessionListener)
|
||||||
// auto permission + start when the camera is plugged in while running
|
// auto permission + start when the camera is plugged in while running
|
||||||
@@ -96,9 +104,12 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
/** Begin USB permission flow, then start streaming. No device -> idle notice. */
|
/** Begin USB permission flow, then start streaming. No device -> idle notice. */
|
||||||
fun connect() {
|
fun connect() {
|
||||||
val context = getApplication<Application>()
|
val context = getApplication<Application>()
|
||||||
|
com.mag160c.thermal.media.DebugLog.startFile(context)
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("vm", "connect()")
|
||||||
val transport = com.mag160c.thermal.usb.UsbTransport(context)
|
val transport = com.mag160c.thermal.usb.UsbTransport(context)
|
||||||
val dev = transport.findDevice()
|
val dev = transport.findDevice()
|
||||||
if (dev == null) {
|
if (dev == null) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("vm", "no_device")
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
connected = false,
|
connected = false,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
@@ -110,8 +121,12 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
|
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
|
||||||
.getOrDefault(ByteArray(0))
|
.getOrDefault(ByteArray(0))
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"vm", "permission ok, ddt ${ddt.size} bytes, starting session",
|
||||||
|
)
|
||||||
session.start(ddt)
|
session.start(ddt)
|
||||||
} else {
|
} else {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("vm", "permission denied")
|
||||||
_state.value = _state.value.copy(connected = false, status = "no_permission")
|
_state.value = _state.value.copy(connected = false, status = "no_permission")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,6 +268,14 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
/** Update per-frame temperature stats (called on a slow timer). */
|
/** Update per-frame temperature stats (called on a slow timer). */
|
||||||
fun refreshTemps() {
|
fun refreshTemps() {
|
||||||
|
if (uiTick++ % 12 == 0) {
|
||||||
|
// ~5 s heartbeat: what the UI currently sees (debug round 11)
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"vm",
|
||||||
|
"hb: state=${_state.value.status} streaming=${session.isStreaming()} " +
|
||||||
|
"uiFrames=$framesRcvd latest=${latestFrame != null}",
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!session.isStreaming()) return
|
if (!session.isStreaming()) return
|
||||||
val center = session.probeTemp(80, 60)
|
val center = session.probeTemp(80, 60)
|
||||||
val nuc = IntArray(19200)
|
val nuc = IntArray(19200)
|
||||||
@@ -260,6 +283,8 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
updateTemps(center, nuc)
|
updateTemps(center, nuc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var uiTick = 0
|
||||||
|
|
||||||
private fun updateTemps(center: Int?, nuc: IntArray) {
|
private fun updateTemps(center: Int?, nuc: IntArray) {
|
||||||
var mn = Int.MAX_VALUE
|
var mn = Int.MAX_VALUE
|
||||||
var mx = -1
|
var mx = -1
|
||||||
|
|||||||
@@ -5,21 +5,25 @@ import android.hardware.usb.UsbDeviceConnection
|
|||||||
import android.hardware.usb.UsbEndpoint
|
import android.hardware.usb.UsbEndpoint
|
||||||
import com.mag160c.thermal.core.FrameStream
|
import com.mag160c.thermal.core.FrameStream
|
||||||
import com.mag160c.thermal.core.RenderPipeline
|
import com.mag160c.thermal.core.RenderPipeline
|
||||||
|
import com.mag160c.thermal.media.DebugLog
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live IR camera session: link -> query info -> start stream -> render.
|
* Live IR camera session: link -> query info -> start stream -> render.
|
||||||
* Ported from csdk/src/mag160c_ir.c + the demo3 FFC cadence:
|
* Ported from csdk/src/mag160c_ir.c + the demo3 FFC cadence:
|
||||||
* prepare: 66b / 66c / 66f (4B each)
|
* prepare: 66b / 66c / 66f (4B each)
|
||||||
* start: reader thread -> 50 ms -> FFC(0) x2 -> 300 ms -> START(73)
|
* start: reader loop -> 50 ms -> FFC(0) x2 -> 300 ms -> START(73)
|
||||||
* stop: STOP(74)
|
* stop: STOP(74)
|
||||||
* FFC commands are emitted by [RenderPipeline.onFfc] to keep the type=0
|
* The FFC(0)x2 + START sequence before reading is REQUIRED (verified on
|
||||||
* stream alive (official cadence).
|
* hardware via the C reference): without it the camera never streams.
|
||||||
|
* Sustained FFC(0)/FFC(1) commands are emitted by [RenderPipeline.onFfc]
|
||||||
|
* (official cadence keeps the type=0 stream alive).
|
||||||
*/
|
*/
|
||||||
class IrSession(context: Context) {
|
class IrSession(context: Context) {
|
||||||
data class CameraIdentity(
|
data class CameraIdentity(
|
||||||
@@ -38,6 +42,11 @@ class IrSession(context: Context) {
|
|||||||
fun onIdentity(identity: CameraIdentity)
|
fun onIdentity(identity: CameraIdentity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of one command exchange on EP 0x03 -> 0x82 (for logging). */
|
||||||
|
data class CmdResult(val written: Int, val read: Int, val magic: Int) {
|
||||||
|
val ok: Boolean get() = written > 0 && read > 3
|
||||||
|
}
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val transport = UsbTransport(context)
|
private val transport = UsbTransport(context)
|
||||||
private var listener: Listener? = null
|
private var listener: Listener? = null
|
||||||
@@ -77,46 +86,91 @@ class IrSession(context: Context) {
|
|||||||
fun start(ddtBytes: ByteArray) {
|
fun start(ddtBytes: ByteArray) {
|
||||||
if (running.get()) return
|
if (running.get()) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
notify(State.LINKING, null)
|
try {
|
||||||
val dev = transport.findDevice()
|
startInternal(ddtBytes)
|
||||||
if (dev == null) {
|
} catch (e: Exception) {
|
||||||
notify(State.ERROR, "no_device")
|
DebugLog.log("session", "FATAL ${e.javaClass.simpleName}: ${e.message}\n" +
|
||||||
return@launch
|
android.util.Log.getStackTraceString(e))
|
||||||
}
|
|
||||||
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()
|
transport.close()
|
||||||
notify(State.ERROR, "no_endpoints")
|
running.set(false)
|
||||||
return@launch
|
notify(State.ERROR, "exception:${e.javaClass.simpleName}")
|
||||||
}
|
}
|
||||||
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 startInternal(ddtBytes: ByteArray) {
|
||||||
|
notify(State.LINKING, null)
|
||||||
|
val dev = transport.findDevice()
|
||||||
|
if (dev == null) {
|
||||||
|
DebugLog.log("session", "no_device (VID 0x833C not in deviceList)")
|
||||||
|
notify(State.ERROR, "no_device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DebugLog.log(
|
||||||
|
"session",
|
||||||
|
"device vid=0x%04X pid=0x%04X class=%d subclass=%d proto=%d".format(
|
||||||
|
Locale.US,
|
||||||
|
dev.vendorId, dev.productId,
|
||||||
|
dev.deviceClass, dev.deviceSubclass, dev.deviceProtocol,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
transport.useDevice(dev)
|
||||||
|
if (!transport.open()) {
|
||||||
|
DebugLog.log("session", "open_fail (openDevice/claimInterface failed, see usb log)")
|
||||||
|
notify(State.ERROR, "open_fail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (epOut, epResp, epStream) = transport.endpoints()
|
||||||
|
DebugLog.log(
|
||||||
|
"session",
|
||||||
|
"endpoints out=${epOut?.let { "0x%02X".format(Locale.US, it.address) } ?: "null"} " +
|
||||||
|
"resp=${epResp?.let { "0x%02X".format(Locale.US, it.address) } ?: "null"} " +
|
||||||
|
"stream=${epStream?.let { "0x%02X".format(Locale.US, it.address) } ?: "null"} " +
|
||||||
|
"maxPkt=${epStream?.maxPacketSize}",
|
||||||
|
)
|
||||||
|
if (epOut == null || epResp == null || epStream == null) {
|
||||||
|
transport.close()
|
||||||
|
notify(State.ERROR, "no_endpoints")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val pipe = RenderPipeline(
|
||||||
|
w = identity.width, h = identity.height,
|
||||||
|
onFfc = { param -> sendCmd(cmd8(MagProtocol.CMD_FFC, param), epOut, epResp, "FFC($param)") },
|
||||||
|
)
|
||||||
|
if (!pipe.loadDdt(ddtBytes)) {
|
||||||
|
DebugLog.log("session", "ddt_fail size=${ddtBytes.size}")
|
||||||
|
transport.close()
|
||||||
|
notify(State.ERROR, "ddt_fail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DebugLog.log("session", "ddt loaded ${ddtBytes.size} bytes")
|
||||||
|
pipeline = pipe
|
||||||
|
|
||||||
|
// prepare sequence (verified hardware: 4-byte commands)
|
||||||
|
sendCmd(cmd4(MagProtocol.CMD_PREPARE1), epOut, epResp, "66b")
|
||||||
|
sendCmd(cmd4(MagProtocol.CMD_PREPARE2), epOut, epResp, "66c")
|
||||||
|
sendCmd(cmd4(MagProtocol.CMD_GET_INFO), epOut, epResp, "66f")
|
||||||
|
notify(State.STREAMING, null)
|
||||||
|
notifyIdentity()
|
||||||
|
DebugLog.log("session", "identity $identity")
|
||||||
|
|
||||||
|
// Startup sequence verified on hardware (csdk mag160c_ir.c): reader
|
||||||
|
// thread first, FFC(0) x2, 300 ms, then START(73). Without START the
|
||||||
|
// camera never streams (root cause of the round-11 black screen).
|
||||||
|
running.set(true)
|
||||||
|
Thread.sleep(50)
|
||||||
|
sendCmd(cmd8(MagProtocol.CMD_FFC, 0), epOut, epResp, "FFC(0) pre-start 1/2")
|
||||||
|
sendCmd(cmd8(MagProtocol.CMD_FFC, 0), epOut, epResp, "FFC(0) pre-start 2/2")
|
||||||
|
Thread.sleep(300)
|
||||||
|
val st = sendCmd(cmd4(MagProtocol.CMD_START), epOut, epResp, "START")
|
||||||
|
if (!st.ok) {
|
||||||
|
DebugLog.log("session", "START rejected/ignored: $st")
|
||||||
|
}
|
||||||
|
streamLoop(pipe, epStream, epOut, epResp)
|
||||||
|
}
|
||||||
|
|
||||||
private fun notify(state: State, message: String?) {
|
private fun notify(state: State, message: String?) {
|
||||||
|
DebugLog.log("session", "state -> $state msg=$message")
|
||||||
listener?.onStateChanged(state, message)
|
listener?.onStateChanged(state, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,14 +181,32 @@ class IrSession(context: Context) {
|
|||||||
private fun cmd4(magic: Int) = MagProtocol.cmd4(magic)
|
private fun cmd4(magic: Int) = MagProtocol.cmd4(magic)
|
||||||
private fun cmd8(magic: Int, param: Int) = MagProtocol.cmd8(magic, param)
|
private fun cmd8(magic: Int, param: Int) = MagProtocol.cmd8(magic, param)
|
||||||
|
|
||||||
private fun sendCmd(packet: ByteArray, out: UsbEndpoint, resp: UsbEndpoint) {
|
private fun sendCmd(packet: ByteArray, out: UsbEndpoint, resp: UsbEndpoint, name: String): CmdResult {
|
||||||
val conn: UsbDeviceConnection = transport.connection() ?: return
|
val conn: UsbDeviceConnection = transport.connection()
|
||||||
|
?: run {
|
||||||
|
DebugLog.log("cmd", "$name: no connection")
|
||||||
|
return CmdResult(0, 0, 0)
|
||||||
|
}
|
||||||
val written = conn.bulkTransfer(out, packet, packet.size, 500)
|
val written = conn.bulkTransfer(out, packet, packet.size, 500)
|
||||||
if (written != packet.size) return
|
if (written != packet.size) {
|
||||||
|
DebugLog.log("cmd", "$name write=$written/${packet.size} (EP 0x03)")
|
||||||
|
return CmdResult(written, 0, 0)
|
||||||
|
}
|
||||||
val buf = ByteArray(0x1000)
|
val buf = ByteArray(0x1000)
|
||||||
val n = conn.bulkTransfer(resp, buf, buf.size, 2000)
|
val n = conn.bulkTransfer(resp, buf, buf.size, 2000)
|
||||||
if (n <= 3) return
|
if (n <= 3) {
|
||||||
|
DebugLog.log("cmd", "$name read=$n bytes (EP 0x82, timeout 2000 ms)")
|
||||||
|
return CmdResult(written, n, 0)
|
||||||
|
}
|
||||||
val magic = MagProtocol.u32(buf, 0)
|
val magic = MagProtocol.u32(buf, 0)
|
||||||
|
DebugLog.log(
|
||||||
|
"cmd", "$name ok: rsp=0x%08X len=%d head=%s".format(
|
||||||
|
Locale.US, magic, n,
|
||||||
|
buf.copyOfRange(4, minOf(n, 20)).joinToString(" ") {
|
||||||
|
"%02X".format(Locale.US, it.toInt() and 0xFF)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) {
|
if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) {
|
||||||
lastInfo1 = buf.copyOfRange(4, 4 + 0x38)
|
lastInfo1 = buf.copyOfRange(4, 4 + 0x38)
|
||||||
}
|
}
|
||||||
@@ -150,7 +222,9 @@ class IrSession(context: Context) {
|
|||||||
fps = if (payload.size >= 0x1C) MagProtocol.u32(payload, 0x18) else identity.fps,
|
fps = if (payload.size >= 0x1C) MagProtocol.u32(payload, 0x18) else identity.fps,
|
||||||
)
|
)
|
||||||
identity = newIdentity
|
identity = newIdentity
|
||||||
|
DebugLog.log("cmd", "camera info: $newIdentity")
|
||||||
}
|
}
|
||||||
|
return CmdResult(written, n, magic)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun streamLoop(
|
private fun streamLoop(
|
||||||
@@ -165,21 +239,81 @@ class IrSession(context: Context) {
|
|||||||
val out = IntArray(320 * 240)
|
val out = IntArray(320 * 240)
|
||||||
val tmp = ByteArray(0x8000)
|
val tmp = ByteArray(0x8000)
|
||||||
val noop = ByteArray(0)
|
val noop = ByteArray(0)
|
||||||
|
var readCount = 0
|
||||||
|
var frameCount = 0
|
||||||
|
var renderCount = 0
|
||||||
|
var timeouts = 0
|
||||||
|
var resyncs = 0
|
||||||
|
val t0 = android.os.SystemClock.elapsedRealtime()
|
||||||
|
var lastLog = t0
|
||||||
|
var first16 = 0
|
||||||
|
DebugLog.log("stream", "reader loop start (buffer 0x8000, timeout 500 ms)")
|
||||||
while (running.get()) {
|
while (running.get()) {
|
||||||
val n = conn.bulkTransfer(epStream, tmp, tmp.size, 500)
|
val n = conn.bulkTransfer(epStream, tmp, tmp.size, 500)
|
||||||
if (n <= 0) continue
|
if (n <= 0) {
|
||||||
|
timeouts++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
readCount++
|
||||||
|
if (first16 < 3) {
|
||||||
|
DebugLog.log(
|
||||||
|
"stream", "first reads [$first16] n=$n head=%s".format(
|
||||||
|
Locale.US,
|
||||||
|
tmp.copyOfRange(0, minOf(n, 24)).joinToString(" ") {
|
||||||
|
"%02X".format(Locale.US, it.toInt() and 0xFF)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
first16++
|
||||||
|
}
|
||||||
var len = stream.push(tmp, n, frameBuf)
|
var len = stream.push(tmp, n, frameBuf)
|
||||||
|
if (len == 0 && n > 0 && ++resyncs % 200 == 1) {
|
||||||
|
// lots of data but no valid frames: log a heartbeat
|
||||||
|
DebugLog.log("stream", "resyncing: reads=$readCount timeouts=$timeouts (no frame)")
|
||||||
|
}
|
||||||
while (len > 0 && running.get()) {
|
while (len > 0 && running.get()) {
|
||||||
|
frameCount++
|
||||||
|
val type = MagProtocol.u32(frameBuf, 12)
|
||||||
|
val counter = MagProtocol.u32(frameBuf, 4)
|
||||||
|
if (frameCount <= 5) {
|
||||||
|
val shutterTail = MagProtocol.u32(frameBuf, 0x1C + 38400 + 8)
|
||||||
|
DebugLog.log(
|
||||||
|
"stream", "frame #$frameCount cnt=$counter type=$type len=$len " +
|
||||||
|
"shutter=0x%X".format(Locale.US, shutterTail),
|
||||||
|
)
|
||||||
|
}
|
||||||
lastRawFrame = frameBuf.copyOf()
|
lastRawFrame = frameBuf.copyOf()
|
||||||
val rendered = pipe.frame(frameBuf, true, out)
|
val rendered = pipe.frame(frameBuf, true, out)
|
||||||
if (rendered) {
|
if (rendered) {
|
||||||
|
renderCount++
|
||||||
listener?.onFrameReady(out)
|
listener?.onFrameReady(out)
|
||||||
recorderHook?.invoke(out)
|
recorderHook?.invoke(out)
|
||||||
}
|
}
|
||||||
len = stream.push(noop, 0, frameBuf)
|
len = stream.push(noop, 0, frameBuf)
|
||||||
}
|
}
|
||||||
|
val now = android.os.SystemClock.elapsedRealtime()
|
||||||
|
if (now - lastLog >= 2000) {
|
||||||
|
val fps = frameCount * 1000f / (now - t0)
|
||||||
|
val last = MagProtocol.u32(frameBuf, 12)
|
||||||
|
DebugLog.log(
|
||||||
|
"stream",
|
||||||
|
"stats: reads=$readCount frames=$frameCount rendered=$renderCount " +
|
||||||
|
"timeouts=$timeouts fps=%.1f lastType=$last " +
|
||||||
|
"renderState=${pipe.frameIndex()} ref=${pipe.hasReference()}".format(
|
||||||
|
Locale.US, fps,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
lastLog = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sendCmd(cmd4(MagProtocol.CMD_STOP), epOut, epResp)
|
val secs = (android.os.SystemClock.elapsedRealtime() - t0) / 1000.0
|
||||||
|
DebugLog.log(
|
||||||
|
"stream",
|
||||||
|
"reader loop end: frames=$frameCount rendered=$renderCount secs=%.1f".format(
|
||||||
|
Locale.US, secs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sendCmd(cmd4(MagProtocol.CMD_STOP), epOut, epResp, "STOP")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Manual FFC (official shutter button / double tap). */
|
/** Manual FFC (official shutter button / double tap). */
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ class UsbTransport(private val context: Context) {
|
|||||||
/** Find the first connected MAG160C device. */
|
/** Find the first connected MAG160C device. */
|
||||||
fun findDevice(): UsbDevice? {
|
fun findDevice(): UsbDevice? {
|
||||||
for (dev in manager.deviceList.values) {
|
for (dev in manager.deviceList.values) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"usb",
|
||||||
|
"device vid=0x%04X pid=0x%04X name=%s".format(
|
||||||
|
java.util.Locale.US,
|
||||||
|
dev.vendorId, dev.productId, dev.deviceName,
|
||||||
|
),
|
||||||
|
)
|
||||||
if (dev.vendorId == VID) return dev
|
if (dev.vendorId == VID) return dev
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -37,20 +44,25 @@ class UsbTransport(private val context: Context) {
|
|||||||
/** USB permission callback: true after approval. */
|
/** USB permission callback: true after approval. */
|
||||||
fun requestPermission(onDone: (Boolean) -> Unit) {
|
fun requestPermission(onDone: (Boolean) -> Unit) {
|
||||||
val dev = findDevice() ?: run {
|
val dev = findDevice() ?: run {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("usb", "requestPermission: no device")
|
||||||
onDone(false)
|
onDone(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (manager.hasPermission(dev)) {
|
if (manager.hasPermission(dev)) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("usb", "permission already granted")
|
||||||
device = dev
|
device = dev
|
||||||
onDone(true)
|
onDone(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("usb", "requesting permission (dialog)")
|
||||||
val action = "com.mag160c.thermal.USB_PERMISSION_ACTION"
|
val action = "com.mag160c.thermal.USB_PERMISSION_ACTION"
|
||||||
val receiver = object : BroadcastReceiver() {
|
val receiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(ctx: Context, intent: Intent) {
|
override fun onReceive(ctx: Context, intent: Intent) {
|
||||||
context.unregisterReceiver(this)
|
context.unregisterReceiver(this)
|
||||||
if (manager.hasPermission(dev)) device = dev
|
val granted = manager.hasPermission(dev)
|
||||||
onDone(manager.hasPermission(dev))
|
com.mag160c.thermal.media.DebugLog.log("usb", "permission result: $granted")
|
||||||
|
if (granted) device = dev
|
||||||
|
onDone(granted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.registerReceiver(
|
context.registerReceiver(
|
||||||
@@ -72,20 +84,39 @@ class UsbTransport(private val context: Context) {
|
|||||||
|
|
||||||
/** Open the device: claim interface 0 and expose endpoints. */
|
/** Open the device: claim interface 0 and expose endpoints. */
|
||||||
fun open(): Boolean {
|
fun open(): Boolean {
|
||||||
val dev = device ?: return false
|
val dev = device ?: run {
|
||||||
val conn = manager.openDevice(dev) ?: return false
|
com.mag160c.thermal.media.DebugLog.log("usb", "open: no device selected")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val conn = manager.openDevice(dev) ?: run {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("usb", "openDevice returned null")
|
||||||
|
return false
|
||||||
|
}
|
||||||
connection = conn
|
connection = conn
|
||||||
val intf = dev.getInterface(0) ?: run { conn.close(); return false }
|
val intf = dev.getInterface(0) ?: run {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("usb", "interface 0 is null (count=${dev.interfaceCount})")
|
||||||
|
conn.close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (!conn.claimInterface(intf, true)) {
|
if (!conn.claimInterface(intf, true)) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("usb", "claimInterface(0, force) failed")
|
||||||
conn.close()
|
conn.close()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
claimedInterface = intf
|
claimedInterface = intf
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"usb",
|
||||||
|
"claimed interface 0: endpoints=${intf.endpointCount} configs=${dev.configurationCount}",
|
||||||
|
)
|
||||||
// Prefer configuration 2 when the device exposes it (vendor behavior).
|
// Prefer configuration 2 when the device exposes it (vendor behavior).
|
||||||
if (dev.configurationCount > 1) {
|
if (dev.configurationCount > 1) {
|
||||||
val cfg = dev.getConfiguration(1)
|
val cfg = dev.getConfiguration(1)
|
||||||
// USB SET_CONFIGURATION request = 9
|
// USB SET_CONFIGURATION request = 9
|
||||||
conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500)
|
val rc = conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500)
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"usb",
|
||||||
|
"setConfiguration(${cfg?.id ?: 2}) controlTransfer rc=$rc",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -96,11 +127,26 @@ class UsbTransport(private val context: Context) {
|
|||||||
var resp: UsbEndpoint? = null
|
var resp: UsbEndpoint? = null
|
||||||
var stream: UsbEndpoint? = null
|
var stream: UsbEndpoint? = null
|
||||||
for (i in 0 until intf.endpointCount) {
|
for (i in 0 until intf.endpointCount) {
|
||||||
when (intf.getEndpoint(i).address) {
|
val ep = intf.getEndpoint(i)
|
||||||
EP_CMD_OUT -> out = intf.getEndpoint(i)
|
when (ep.address) {
|
||||||
EP_CMD_IN -> resp = intf.getEndpoint(i)
|
EP_CMD_OUT -> out = ep
|
||||||
EP_STREAM_IN -> stream = intf.getEndpoint(i)
|
EP_CMD_IN -> resp = ep
|
||||||
|
EP_STREAM_IN -> stream = ep
|
||||||
}
|
}
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"usb",
|
||||||
|
"ep[$i] addr=0x%02X dir=%s type=%d maxPkt=%d".format(
|
||||||
|
java.util.Locale.US,
|
||||||
|
ep.address,
|
||||||
|
when (ep.direction) {
|
||||||
|
UsbConstants.USB_DIR_OUT -> "OUT"
|
||||||
|
UsbConstants.USB_DIR_IN -> "IN"
|
||||||
|
else -> "?"
|
||||||
|
},
|
||||||
|
ep.type,
|
||||||
|
ep.maxPacketSize,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return Triple(out, resp, stream)
|
return Triple(out, resp, stream)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -207,9 +207,36 @@
|
|||||||
- 待办不变(真机 USB 实测温度标定、网络互连远程预览、离线MDT温度解码、
|
- 待办不变(真机 USB 实测温度标定、网络互连远程预览、离线MDT温度解码、
|
||||||
调色板精确提取、PIP/云模块)。
|
调色板精确提取、PIP/云模块)。
|
||||||
|
|
||||||
|
## 用户反馈修复 第十一轮(2026-09-09,真机黑屏:日志落盘 + 修复缺失 START)
|
||||||
|
|
||||||
|
- [x] **用户反馈**:真机插热像仪有 USB 弹窗,但实时画面黑屏;要求把 debug 日志
|
||||||
|
保存到热成像相册目录,运行后把文件发回分析。
|
||||||
|
- [x] **黑屏根因(代码审查定位)**:`IrSession.start` 移植时漏掉了硬件已验证的
|
||||||
|
启动序列(csdk `mag160c_ir.c`):读循环前必须 FFC(0)×2 → 300ms → START(73);
|
||||||
|
旧代码只发 66b/66c/66f 就进读循环,相机从未出流 → EP 0x81 静默 → 黑屏。
|
||||||
|
已按 C 参考补上(50ms → FFC(0)×2 → 300ms → START)。
|
||||||
|
- [x] **新增 `media/DebugLog.kt`**:每次 connect() 在 MediaStore 建一个
|
||||||
|
`debug_yyyyMMdd_HHmmss.log`(DCIM/MAG160C,text/plain,API29+ 无需权限,
|
||||||
|
相册/文件管理器可见),逐行 flush;同时镜像到 logcat(tag `MAG160C/*`);
|
||||||
|
MainActivity 装崩溃钩子(栈回写文件后再交前 handler)。
|
||||||
|
- [x] **全链路插桩**:UsbTransport(设备列表/权限结果/openDevice/claim/端点表)、
|
||||||
|
IrSession(命令交换 write/read/响应 magic+头16字节、首3次读头24字节、
|
||||||
|
前5帧 type/shutter、每2s 心跳 stats reads/frames/rendered/timeouts/fps/
|
||||||
|
renderState/ref、STOP)、LiveViewModel(connect/权限/首帧到达/5s 心跳)。
|
||||||
|
- [x] 状态文案补全:open_fail/no_endpoints/exception:* 在 LiveScreen 显示
|
||||||
|
具体提示(原先一律"连接中…",掩盖故障)。
|
||||||
|
- [x] 构建+单测全过;dex 抽查中文/日志器/启动序列字符串正常;APK 已更新
|
||||||
|
(11.87MB)。调试日志要点:若仍黑屏,看文件里 `stats: reads=?` ——
|
||||||
|
reads=0 → START 被无视(查 cmd 日志);frames>0 rendered=0 →
|
||||||
|
warm/FFC/ref 窗口(ref=false 持续 → 66c 响应异常);rendered>0 仍黑屏 →
|
||||||
|
UI/渲染层问题(hb: uiFrames=?)。
|
||||||
|
- 注:调试日志文件由 MediaStore Files(非 Images)写入,部分相册 app 不显示
|
||||||
|
text/plain,可用系统"文件"应用或 PC 复制 DCIM/MAG160C/debug_*.log。
|
||||||
|
|
||||||
## 待办
|
## 待办
|
||||||
|
|
||||||
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)
|
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——当前进行中:
|
||||||
|
第11轮日志落盘 APK 待用户回传 debug_*.log 分析
|
||||||
- 网络互连远程预览(用户需求:UDP自动发现+手动IP)
|
- 网络互连远程预览(用户需求:UDP自动发现+手动IP)
|
||||||
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
|
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
|
||||||
- 厂商12调色板精确提取(需运行时抓取)
|
- 厂商12调色板精确提取(需运行时抓取)
|
||||||
|
|||||||
Reference in New Issue
Block a user