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() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// field-debug crash hook + logcat/file logger (file opens on connect)
|
||||
com.mag160c.thermal.media.DebugLog.init(applicationContext)
|
||||
enableEdgeToEdge()
|
||||
// immersive: hide the status bar (swipe to reveal)
|
||||
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_permission" -> "USB权限未授予"
|
||||
"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
|
||||
|
||||
@@ -52,6 +52,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val sessionListener = object : IrSession.Listener {
|
||||
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(
|
||||
connected = state == IrSession.State.STREAMING,
|
||||
streaming = state == IrSession.State.STREAMING,
|
||||
@@ -60,6 +61,10 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
override fun onFrameReady(argb: IntArray) {
|
||||
if (framesRcvd == 0) {
|
||||
com.mag160c.thermal.media.DebugLog.log("vm", "first rendered frame -> UI")
|
||||
}
|
||||
framesRcvd++
|
||||
latestFrame = argb
|
||||
}
|
||||
|
||||
@@ -68,6 +73,9 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var framesRcvd = 0
|
||||
|
||||
init {
|
||||
session.setListener(sessionListener)
|
||||
// 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. */
|
||||
fun connect() {
|
||||
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 dev = transport.findDevice()
|
||||
if (dev == null) {
|
||||
com.mag160c.thermal.media.DebugLog.log("vm", "no_device")
|
||||
_state.value = _state.value.copy(
|
||||
connected = false,
|
||||
streaming = false,
|
||||
@@ -110,8 +121,12 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
if (ok) {
|
||||
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
|
||||
.getOrDefault(ByteArray(0))
|
||||
com.mag160c.thermal.media.DebugLog.log(
|
||||
"vm", "permission ok, ddt ${ddt.size} bytes, starting session",
|
||||
)
|
||||
session.start(ddt)
|
||||
} else {
|
||||
com.mag160c.thermal.media.DebugLog.log("vm", "permission denied")
|
||||
_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). */
|
||||
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
|
||||
val center = session.probeTemp(80, 60)
|
||||
val nuc = IntArray(19200)
|
||||
@@ -260,6 +283,8 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
updateTemps(center, nuc)
|
||||
}
|
||||
|
||||
private var uiTick = 0
|
||||
|
||||
private fun updateTemps(center: Int?, nuc: IntArray) {
|
||||
var mn = Int.MAX_VALUE
|
||||
var mx = -1
|
||||
|
||||
@@ -5,21 +5,25 @@ import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import com.mag160c.thermal.core.FrameStream
|
||||
import com.mag160c.thermal.core.RenderPipeline
|
||||
import com.mag160c.thermal.media.DebugLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
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)
|
||||
* start: reader loop -> 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).
|
||||
* The FFC(0)x2 + START sequence before reading is REQUIRED (verified on
|
||||
* 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) {
|
||||
data class CameraIdentity(
|
||||
@@ -38,6 +42,11 @@ class IrSession(context: Context) {
|
||||
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 transport = UsbTransport(context)
|
||||
private var listener: Listener? = null
|
||||
@@ -77,46 +86,91 @@ class IrSession(context: Context) {
|
||||
fun start(ddtBytes: ByteArray) {
|
||||
if (running.get()) return
|
||||
scope.launch {
|
||||
try {
|
||||
startInternal(ddtBytes)
|
||||
} catch (e: Exception) {
|
||||
DebugLog.log("session", "FATAL ${e.javaClass.simpleName}: ${e.message}\n" +
|
||||
android.util.Log.getStackTraceString(e))
|
||||
transport.close()
|
||||
running.set(false)
|
||||
notify(State.ERROR, "exception:${e.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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@launch
|
||||
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@launch
|
||||
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@launch
|
||||
return
|
||||
}
|
||||
val pipe = RenderPipeline(
|
||||
w = identity.width, h = identity.height,
|
||||
onFfc = { param -> sendCmd(cmd8(MagProtocol.CMD_FFC, param), epOut, epResp) },
|
||||
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@launch
|
||||
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)
|
||||
sendCmd(cmd4(MagProtocol.CMD_PREPARE2), epOut, epResp)
|
||||
sendCmd(cmd4(MagProtocol.CMD_GET_INFO), epOut, epResp)
|
||||
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)
|
||||
streamLoop(pipe, epStream, epOut, epResp)
|
||||
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?) {
|
||||
DebugLog.log("session", "state -> $state msg=$message")
|
||||
listener?.onStateChanged(state, message)
|
||||
}
|
||||
|
||||
@@ -127,14 +181,32 @@ class IrSession(context: Context) {
|
||||
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
|
||||
private fun sendCmd(packet: ByteArray, out: UsbEndpoint, resp: UsbEndpoint, name: String): CmdResult {
|
||||
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)
|
||||
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 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)
|
||||
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) {
|
||||
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,
|
||||
)
|
||||
identity = newIdentity
|
||||
DebugLog.log("cmd", "camera info: $newIdentity")
|
||||
}
|
||||
return CmdResult(written, n, magic)
|
||||
}
|
||||
|
||||
private fun streamLoop(
|
||||
@@ -165,21 +239,81 @@ class IrSession(context: Context) {
|
||||
val out = IntArray(320 * 240)
|
||||
val tmp = ByteArray(0x8000)
|
||||
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()) {
|
||||
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)
|
||||
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()) {
|
||||
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()
|
||||
val rendered = pipe.frame(frameBuf, true, out)
|
||||
if (rendered) {
|
||||
renderCount++
|
||||
listener?.onFrameReady(out)
|
||||
recorderHook?.invoke(out)
|
||||
}
|
||||
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). */
|
||||
|
||||
@@ -29,6 +29,13 @@ class UsbTransport(private val context: Context) {
|
||||
/** Find the first connected MAG160C device. */
|
||||
fun findDevice(): UsbDevice? {
|
||||
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
|
||||
}
|
||||
return null
|
||||
@@ -37,20 +44,25 @@ class UsbTransport(private val context: Context) {
|
||||
/** USB permission callback: true after approval. */
|
||||
fun requestPermission(onDone: (Boolean) -> Unit) {
|
||||
val dev = findDevice() ?: run {
|
||||
com.mag160c.thermal.media.DebugLog.log("usb", "requestPermission: no device")
|
||||
onDone(false)
|
||||
return
|
||||
}
|
||||
if (manager.hasPermission(dev)) {
|
||||
com.mag160c.thermal.media.DebugLog.log("usb", "permission already granted")
|
||||
device = dev
|
||||
onDone(true)
|
||||
return
|
||||
}
|
||||
com.mag160c.thermal.media.DebugLog.log("usb", "requesting permission (dialog)")
|
||||
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))
|
||||
val granted = manager.hasPermission(dev)
|
||||
com.mag160c.thermal.media.DebugLog.log("usb", "permission result: $granted")
|
||||
if (granted) device = dev
|
||||
onDone(granted)
|
||||
}
|
||||
}
|
||||
context.registerReceiver(
|
||||
@@ -72,20 +84,39 @@ class UsbTransport(private val context: Context) {
|
||||
|
||||
/** Open the device: claim interface 0 and expose endpoints. */
|
||||
fun open(): Boolean {
|
||||
val dev = device ?: return false
|
||||
val conn = manager.openDevice(dev) ?: return false
|
||||
val dev = device ?: run {
|
||||
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
|
||||
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)) {
|
||||
com.mag160c.thermal.media.DebugLog.log("usb", "claimInterface(0, force) failed")
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
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).
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -96,11 +127,26 @@ class UsbTransport(private val context: Context) {
|
||||
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)
|
||||
val ep = intf.getEndpoint(i)
|
||||
when (ep.address) {
|
||||
EP_CMD_OUT -> out = ep
|
||||
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)
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -207,9 +207,36 @@
|
||||
- 待办不变(真机 USB 实测温度标定、网络互连远程预览、离线MDT温度解码、
|
||||
调色板精确提取、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)
|
||||
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
|
||||
- 厂商12调色板精确提取(需运行时抓取)
|
||||
|
||||
Reference in New Issue
Block a user