diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt index 22303e8..c92bcbe 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt @@ -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 -> diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/DebugLog.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/DebugLog.kt new file mode 100644 index 0000000..ec07435 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/DebugLog.kt @@ -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/). */ + 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 + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index 1bac758..fd1af1f 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index 83e9b74..8b6f240 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -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() + 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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt index 8ac5ef9..8c5642e 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt @@ -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 { - 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) { + try { + startInternal(ddtBytes) + } catch (e: Exception) { + DebugLog.log("session", "FATAL ${e.javaClass.simpleName}: ${e.message}\n" + + android.util.Log.getStackTraceString(e)) transport.close() - notify(State.ERROR, "no_endpoints") - return@launch + running.set(false) + 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?) { + 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). */ diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt index 05cf0ee..adaca34 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt @@ -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) } diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 8ce6814..d1a9244 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f8b898035e65a02d75a8e23828e1716ed6aeaea1f959cd087a6173fe91272f9 -size 11806269 +oid sha256:756e2a691fc1e16820b0ee4cf216cd1809a252fc8b2ada78e68e295f6c30bf8d +size 11872694 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 5277cb7..f693f7a 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -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调色板精确提取(需运行时抓取)