android: fix black screen (missing FFC(0)x2+START pre-stream sequence) + field debug log to DCIM/MAG160C

This commit is contained in:
ZXCLI
2026-09-10 00:01:25 +08:00
parent fe8595f747
commit ee317c7b25
8 changed files with 425 additions and 57 deletions
@@ -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.
+28 -1
View File
@@ -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/MAG160Ctext/plainAPI29+ 无需权限,
相册/文件管理器可见),逐行 flush;同时镜像到 logcattag `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)、LiveViewModelconnect/权限/首帧到达/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调色板精确提取(需运行时抓取)