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() { 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.
+28 -1
View File
@@ -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/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) - 网络互连远程预览(用户需求:UDP自动发现+手动IP)
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照) - 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
- 厂商12调色板精确提取(需运行时抓取) - 厂商12调色板精确提取(需运行时抓取)