From 0f2aa11196168c910393db3648688d045ebc6a1d Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Thu, 10 Sep 2026 01:21:29 +0800 Subject: [PATCH] android: USB hardening from device logs - async stream reader, CLEAR_HALT recovery, session mutex, detach handling, fast init --- .../com/mag160c/thermal/ui/live/LiveScreen.kt | 3 +- .../mag160c/thermal/ui/live/LiveViewModel.kt | 42 +++- .../com/mag160c/thermal/usb/IrSession.kt | 213 +++++++++++++----- build-artifacts/mag160c-app-debug.apk | 4 +- docs/android_app/session_state.md | 26 ++- 5 files changed, 229 insertions(+), 59 deletions(-) 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 6c51ae0..bc37c7d 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 @@ -99,7 +99,7 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {}) }, ) - if (!state.connected) { + if (!state.connected || state.status == "no_stream_data") { Text( text = statusText(state), modifier = Modifier @@ -296,6 +296,7 @@ private fun statusText(state: LiveViewModel.LiveState): String = when (state.sta "open_fail" -> "USB打开失败(查看调试日志)" "no_endpoints" -> "未找到数据端点(查看调试日志)" "connect_fail" -> "连接流程异常(查看调试日志)" + "no_stream_data" -> "已连接但无数据流(10秒),日志已记录" else -> { val prefix = if (state.status.startsWith("exception:")) { "异常:" + state.status.removePrefix("exception:") 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 dfea4d4..46085eb 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 @@ -76,10 +76,14 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { @Volatile private var framesRcvd = 0 + private var usbDetachReceiver: android.content.BroadcastReceiver? = null + + private var lastConnectMs = 0L + init { session.setListener(sessionListener) - // auto permission + start when the camera is plugged in while running val ctx = getApplication() + // auto permission + start when the camera is plugged in while running usbReceiver = object : android.content.BroadcastReceiver() { override fun onReceive(c: android.content.Context?, i: android.content.Intent?) { connect() @@ -90,6 +94,36 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { android.content.IntentFilter(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED), if (android.os.Build.VERSION.SDK_INT >= 33) android.content.Context.RECEIVER_NOT_EXPORTED else 0, ) + // camera re-enumerates (power blip / reboot): release the dead session + usbDetachReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(c: android.content.Context?, i: android.content.Intent?) { + val dev: android.hardware.usb.UsbDevice? = if (android.os.Build.VERSION.SDK_INT >= 33) { + i?.getParcelableExtra( + android.hardware.usb.UsbManager.EXTRA_DEVICE, + android.hardware.usb.UsbDevice::class.java, + ) + } else { + @Suppress("DEPRECATION") + i?.getParcelableExtra(android.hardware.usb.UsbManager.EXTRA_DEVICE) + } + com.mag160c.thermal.media.DebugLog.log( + "vm", "usb detached vid=0x%04X".format(java.util.Locale.US, dev?.vendorId ?: 0), + ) + if (dev?.vendorId == 0x833C) { + session.stop() + _state.value = _state.value.copy( + connected = false, + streaming = false, + status = "no_device", + ) + } + } + } + ctx.registerReceiver( + usbDetachReceiver, + android.content.IntentFilter(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED), + if (android.os.Build.VERSION.SDK_INT >= 33) android.content.Context.RECEIVER_NOT_EXPORTED else 0, + ) // push frames into the MP4 recorder while recording session.recorderHook = { argb -> val rec = recorder @@ -103,6 +137,12 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { /** Begin USB permission flow, then start streaming. No device -> idle notice. */ fun connect() { + val now = android.os.SystemClock.elapsedRealtime() + if (now - lastConnectMs < 800) { + com.mag160c.thermal.media.DebugLog.log("vm", "connect() debounced") + return + } + lastConnectMs = now try { com.mag160c.thermal.media.DebugLog.log("vm", "connect()") val context = getApplication() 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 8c5642e..8594f99 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 @@ -3,6 +3,7 @@ package com.mag160c.thermal.usb import android.content.Context import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbRequest import com.mag160c.thermal.core.FrameStream import com.mag160c.thermal.core.RenderPipeline import com.mag160c.thermal.media.DebugLog @@ -11,19 +12,25 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.nio.ByteBuffer 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 loop -> 50 ms -> FFC(0) x2 -> 300 ms -> START(73) + * prepare: 66b / 66c / 66f (4B each, responses may be absent on phones) + * start: 50 ms -> FFC(0) x2 -> 300 ms -> START(73) * stop: STOP(74) - * 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). + * Sustained FFC(0)/FFC(1) commands are emitted by [RenderPipeline.onFfc]. + * + * Round-13 hardening from real-device logs (vivo, EP silence + re-enum): + * - stream endpoint read via async UsbRequest (API 30+), sync fallback; + * - every failed transfer logs the endpoint status and tries + * CLEAR_FEATURE(HALT) once, then retries (Android never clears halts); + * - only ONE session may own the camera (a second claimInterface steals + * the interface from the first and both die); + * - init command reads are short (responses are advisory, as in C). */ class IrSession(context: Context) { data class CameraIdentity( @@ -47,15 +54,24 @@ class IrSession(context: Context) { val ok: Boolean get() = written > 0 && read > 3 } + companion object { + /** The single session allowed to own the camera across view models. */ + @Volatile + private var active: IrSession? = null + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val transport = UsbTransport(context) private var listener: Listener? = null private var pipeline: RenderPipeline? = null private val running = AtomicBoolean(false) private var streaming = false - private var identity = CameraIdentity(1, 0, 160, 120, 15) + /** Pending async stream request (cancelled by [stop] to unblock requestWait). */ + @Volatile + private var streamRequest: UsbRequest? = null + fun setListener(l: Listener?) { listener = l } @@ -100,6 +116,14 @@ class IrSession(context: Context) { private fun startInternal(ddtBytes: ByteArray) { notify(State.LINKING, null) + // one camera, one owner: a stale session holding the device would + // otherwise be robbed by our claimInterface and both would stall + active?.let { prev -> + if (prev !== this) { + DebugLog.log("session", "stopping stale previous session") + prev.stop() + } + } val dev = transport.findDevice() if (dev == null) { DebugLog.log("session", "no_device (VID 0x833C not in deviceList)") @@ -121,13 +145,6 @@ class IrSession(context: Context) { 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") @@ -135,7 +152,9 @@ class IrSession(context: Context) { } val pipe = RenderPipeline( w = identity.width, h = identity.height, - onFfc = { param -> sendCmd(cmd8(MagProtocol.CMD_FFC, param), epOut, epResp, "FFC($param)") }, + onFfc = { param -> + sendCmd(cmd8(MagProtocol.CMD_FFC, param), epOut, epResp, "FFC($param)") + }, ) if (!pipe.loadDdt(ddtBytes)) { DebugLog.log("session", "ddt_fail size=${ddtBytes.size}") @@ -145,11 +164,13 @@ class IrSession(context: Context) { } DebugLog.log("session", "ddt loaded ${ddtBytes.size} bytes") pipeline = pipe + active = this - // 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") + // prepare sequence (verified hardware: 4-byte commands; responses + // advisory — the C reference ignores them, so short reads here) + sendCmd(cmd4(MagProtocol.CMD_PREPARE1), epOut, epResp, "66b", 400) + sendCmd(cmd4(MagProtocol.CMD_PREPARE2), epOut, epResp, "66c", 400) + sendCmd(cmd4(MagProtocol.CMD_GET_INFO), epOut, epResp, "66f", 400) notify(State.STREAMING, null) notifyIdentity() DebugLog.log("session", "identity $identity") @@ -159,13 +180,10 @@ class IrSession(context: Context) { // 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") + sendCmd(cmd8(MagProtocol.CMD_FFC, 0), epOut, epResp, "FFC(0) pre-start 1/2", 400) + sendCmd(cmd8(MagProtocol.CMD_FFC, 0), epOut, epResp, "FFC(0) pre-start 2/2", 400) Thread.sleep(300) - val st = sendCmd(cmd4(MagProtocol.CMD_START), epOut, epResp, "START") - if (!st.ok) { - DebugLog.log("session", "START rejected/ignored: $st") - } + sendCmd(cmd4(MagProtocol.CMD_START), epOut, epResp, "START", 400) streamLoop(pipe, epStream, epOut, epResp) } @@ -181,21 +199,47 @@ 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, name: String): CmdResult { + /** Log endpoint status + clear a possible halt; returns clear rc. */ + private fun diagnoseEndpoint(conn: UsbDeviceConnection, epAddr: Int) { + val st = ByteArray(2) + val src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100) + val halted = if (src == 2) (st[0].toInt() and 0x01) else -1 + val clr = conn.controlTransfer(0x02, 1, 0, epAddr, null, 0, 100) + DebugLog.log( + "usb", + "ep 0x%02X get_status rc=%d halted=%d clear_halt rc=%d".format( + Locale.US, epAddr, src, halted, clr, + ), + ) + } + + private fun sendCmd( + packet: ByteArray, + out: UsbEndpoint, + resp: UsbEndpoint, + name: String, + respTimeoutMs: Int = 2000, + ): 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) + var written = conn.bulkTransfer(out, packet, packet.size, 500) if (written != packet.size) { - DebugLog.log("cmd", "$name write=$written/${packet.size} (EP 0x03)") - return CmdResult(written, 0, 0) + DebugLog.log("cmd", "$name write=$written/${packet.size} -> diagnose + retry") + diagnoseEndpoint(conn, out.address) + written = conn.bulkTransfer(out, packet, packet.size, 500) + if (written != packet.size) { + DebugLog.log("cmd", "$name retry write=$written (EP 0x03 dead)") + return CmdResult(written, 0, 0) + } } val buf = ByteArray(0x1000) - val n = conn.bulkTransfer(resp, buf, buf.size, 2000) + val n = conn.bulkTransfer(resp, buf, buf.size, respTimeoutMs) if (n <= 3) { - DebugLog.log("cmd", "$name read=$n bytes (EP 0x82, timeout 2000 ms)") + diagnoseEndpoint(conn, resp.address) + DebugLog.log("cmd", "$name read=$n bytes (EP 0x82, ${respTimeoutMs} ms)") return CmdResult(written, n, 0) } val magic = MagProtocol.u32(buf, 0) @@ -239,38 +283,86 @@ class IrSession(context: Context) { val out = IntArray(320 * 240) val tmp = ByteArray(0x8000) val noop = ByteArray(0) + + // async stream reader on API 30+ (sync bulkTransfer proved unreliable + // on the vivo build: zero stream bytes); sync fallback below API 30 + val async = android.os.Build.VERSION.SDK_INT >= 30 + val bb = if (async) ByteBuffer.allocateDirect(tmp.size) else null + if (async && bb != null) { + val req = UsbRequest() + if (req.initialize(conn, epStream) && req.queue(bb)) { + streamRequest = req + DebugLog.log( + "stream", + "reader loop start: async UsbRequest on EP 0x%02X".format( + Locale.US, epStream.address, + ), + ) + } else { + DebugLog.log("stream", "async init failed -> sync bulkTransfer fallback") + } + } else { + DebugLog.log("stream", "reader loop start: sync bulkTransfer") + } + 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)") + var firstReads = 0 + var noDataNotified = false + while (running.get()) { - val n = conn.bulkTransfer(epStream, tmp, tmp.size, 500) - if (n <= 0) { - timeouts++ - continue + val n: Int + val req = streamRequest + if (req != null && bb != null) { + val done = conn.requestWait() + if (done == null) { + DebugLog.log("stream", "requestWait null (closed?)") + break + } + if (done !== req) continue + // documented ByteBuffer contract: position = bytes transferred + n = bb.position() + if (n > 0) { + bb.flip() + bb.get(tmp, 0, n) + } + bb.clear() + if (!req.queue(bb)) { + DebugLog.log("stream", "requeue failed -> diagnose + retry once") + diagnoseEndpoint(conn, epStream.address) + if (!req.queue(bb)) { + DebugLog.log("stream", "requeue failed twice -> async off") + streamRequest = null + } + } + if (n <= 0) { + timeouts++ + continue + } + } else { + n = conn.bulkTransfer(epStream, tmp, tmp.size, 500) + if (n <= 0) { + timeouts++ + continue + } } readCount++ - if (first16 < 3) { + if (firstReads < 3) { DebugLog.log( - "stream", "first reads [$first16] n=$n head=%s".format( + "stream", "first reads [$firstReads] n=$n head=%s".format( Locale.US, tmp.copyOfRange(0, minOf(n, 24)).joinToString(" ") { "%02X".format(Locale.US, it.toInt() and 0xFF) }, ), ) - first16++ + firstReads++ } 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) @@ -293,17 +385,18 @@ class IrSession(context: Context) { } val now = android.os.SystemClock.elapsedRealtime() if (now - lastLog >= 2000) { - val fps = frameCount * 1000f / (now - t0) - val last = MagProtocol.u32(frameBuf, 12) + val fps = frameCount * 1000f / (now - t0).coerceAtLeast(1) 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, - ), + "timeouts=$timeouts fps=%.1f ".format(Locale.US, fps) + + "renderState=${pipe.frameIndex()} ref=${pipe.hasReference()}", ) lastLog = now + if (readCount == 0 && now - t0 > 10000 && !noDataNotified) { + noDataNotified = true + notify(State.STREAMING, "no_stream_data") + } } } val secs = (android.os.SystemClock.elapsedRealtime() - t0) / 1000.0 @@ -313,7 +406,18 @@ class IrSession(context: Context) { Locale.US, secs, ), ) + // teardown owned HERE (stop() only flips the flag): STOP then close, + // so the STOP actually reaches the camera + val stopReq = streamRequest + streamRequest = null + try { + stopReq?.cancel() + } catch (_: Exception) { + } + if (active === this) active = null sendCmd(cmd4(MagProtocol.CMD_STOP), epOut, epResp, "STOP") + transport.close() + notify(State.IDLE, null) } /** Manual FFC (official shutter button / double tap). */ @@ -340,8 +444,11 @@ class IrSession(context: Context) { fun stop() { if (!running.getAndSet(false)) return pipeline = null - transport.close() - notify(State.IDLE, null) + // unblock a pending async read so the loop can exit and tear down + try { + streamRequest?.cancel() + } catch (_: Exception) { + } } fun destroy() { diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 75bf806..7b5c781 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:009e731cf9d80aa2419bf7e5f5217de23f210735dcd9af9f0e5e551cf614bf3c -size 11872829 +oid sha256:15062fbcc1e293b631d96f099c53ed00552d251e00a095ae4dde0f0c2d28b35f +size 11873652 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 1dc4b10..db0ad53 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -249,10 +249,32 @@ - 注:取日志时 DCIM/MAG160C 和 Download/MAG160C 都看一眼(文件头 sink= 注明 实际位置);若在前者失败会自动落后者。 +## 用户反馈修复 第十三轮(2026-09-10,真机日志分析:端点静默 + 重枚举) + +- [x] **用户回传 debug 日志**(vivo V2509A, Android16):相机识别/DDT/66b 响应全正常 + (SN 160043865),但 66c/66f 响应超时、FFC/START 第一轮写失败(write=-1, + 端点疑似 halt)、流端点 19 秒 0 字节;00:51:10 相机重新枚举(权限再次弹出) + ——相机在会话中途疑似断电/复位;且新旧两个 Activity 的会话同时在抢同一设备 + (claimInterface 互相夺走)。 +- [x] **IrSession 加固**:流端点改 **UsbRequest 异步**(API30+,position=字节数; + 低版本回退同步 bulkTransfer);任何 transfer 失败先 GET_STATUS 诊断 + + **CLEAR_FEATURE(HALT)** 再重试一次;66b/66c/66f 响应读取缩短为 400ms 且 + 失败不阻断(与 C 参考一致);**会话互斥**(companion active,新会话先停旧 + 会话);stop() 时序修复(STOP 在 close 之前发,由 streamLoop 收尾)。 +- [x] **LiveViewModel**:注册 USB **DETACHED** 接收器(VID 匹配→停会话+no_device); + connect() 800ms 去抖;流 10 秒 0 数据 notify `no_stream_data` + (LiveScreen 屏显"已连接但无数据流")。 +- [x] 构建+单测全过;APK 已更新(11.87MB)。 +- ⚠️ **下一步排查(若仍无流)**:相机中途重枚举强烈怀疑 **OTG 供电不足** + (vivo 口限流 → 相机 MCU 帧处理起来后掉电复位)。请用户:① 换一根好点的 + 短线/带供电的 OTG 转接器再试;② 在同一台手机装官方普通版 MAG-Cx 对照 + (若官方也掉,就是供电/兼容问题,与我们的代码无关);③ 新日志看 + `usb detached` 与 `get_status halted=` 行。 + ## 待办 -- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——当前进行中: - 第11轮日志落盘 APK 待用户回传 debug_*.log 分析 +- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——进行中: + 第13轮加固 APK 待用户复测;若仍无流,按上方供电排查三步走 - 网络互连远程预览(用户需求:UDP自动发现+手动IP) - 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照) - 厂商12调色板精确提取(需运行时抓取)