From 1a2fc7abb862bd59553af03ca16b7044bd654b25 Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Thu, 10 Sep 2026 01:38:25 +0800 Subject: [PATCH] android: clear usbfs endpoint-halt after every timed-out transfer (stream killer); drop async reader, add START re-kick --- .../com/mag160c/thermal/usb/IrSession.kt | 135 +++++++----------- build-artifacts/mag160c-app-debug.apk | 2 +- docs/android_app/session_state.md | 19 +++ 3 files changed, 68 insertions(+), 88 deletions(-) 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 8594f99..f472477 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,7 +3,6 @@ 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 @@ -12,7 +11,6 @@ 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 @@ -24,13 +22,16 @@ import java.util.concurrent.atomic.AtomicBoolean * stop: STOP(74) * 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); + * Round-13/14 hardening from real-device logs (vivo V2509A, EP silence): + * - KEY FIX (round 14): Linux/Android usbfs marks an endpoint HALTED after + * a timed-out bulk transfer, and every later transfer then fails instantly + * with -1 until CLEAR_FEATURE(HALT). The first 500 ms stream read (the + * camera needs 1-2 s to start sending) therefore killed the whole stream. + * Now EVERY failed transfer is followed by get_status + clear_halt. * - 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). + * - init command reads are short (responses are advisory, as in C); + * - zero stream bytes for 5 s -> one START re-kick; 10 s -> UI notice. */ class IrSession(context: Context) { data class CameraIdentity( @@ -68,10 +69,6 @@ class IrSession(context: Context) { 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 } @@ -153,7 +150,7 @@ 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)") + sendCmd(cmd8(MagProtocol.CMD_FFC, param), epOut, epResp, "FFC($param)", 400) }, ) if (!pipe.loadDdt(ddtBytes)) { @@ -199,18 +196,21 @@ class IrSession(context: Context) { private fun cmd4(magic: Int) = MagProtocol.cmd4(magic) private fun cmd8(magic: Int, param: Int) = MagProtocol.cmd8(magic, param) - /** Log endpoint status + clear a possible halt; returns clear rc. */ - private fun diagnoseEndpoint(conn: UsbDeviceConnection, epAddr: Int) { + /** Log endpoint status + clear a possible halt; returns clear rc. + * [failureCount] throttles logging in hot loops (first 5, then 1/100). */ + private fun diagnoseEndpoint(conn: UsbDeviceConnection, epAddr: Int, failureCount: Int = 0) { 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, - ), - ) + if (failureCount <= 5 || failureCount % 100 == 0) { + DebugLog.log( + "usb", + "ep 0x%02X fail#$failureCount get_status rc=%d halted=%d clear_halt rc=%d".format( + Locale.US, epAddr, src, halted, clr, + ), + ) + } } private fun sendCmd( @@ -283,27 +283,7 @@ 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") - } + DebugLog.log("stream", "reader loop start: sync bulkTransfer (500 ms) + halt recovery") var readCount = 0 var frameCount = 0 @@ -313,42 +293,36 @@ class IrSession(context: Context) { var lastLog = t0 var firstReads = 0 var noDataNotified = false + var rekicked = false while (running.get()) { - 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 + val n = conn.bulkTransfer(epStream, tmp, tmp.size, 500) + if (n <= 0) { + timeouts++ + // usbfs marks the endpoint halted after a timed-out transfer; + // every later transfer then fails instantly until cleared. + // Clear on EVERY failure or one timeout kills the stream. + diagnoseEndpoint(conn, epStream.address, timeouts) + val now = android.os.SystemClock.elapsedRealtime() + if (readCount == 0 && !rekicked && now - t0 > 5000) { + // camera never delivered a byte: re-kick it once + rekicked = true + DebugLog.log("stream", "no data after 5 s -> re-kick START") + sendCmd(cmd4(MagProtocol.CMD_START), epOut, epResp, "START re-kick", 400) } - if (done !== req) continue - // documented ByteBuffer contract: position = bytes transferred - n = bb.position() - if (n > 0) { - bb.flip() - bb.get(tmp, 0, n) + if (readCount == 0 && now - t0 > 10000 && !noDataNotified) { + noDataNotified = true + notify(State.STREAMING, "no_stream_data") } - 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 + if (now - lastLog >= 2000) { + DebugLog.log( + "stream", + "stats: reads=$readCount frames=$frameCount rendered=$renderCount " + + "timeouts=$timeouts (no data yet)", + ) + lastLog = now } + continue } readCount++ if (firstReads < 3) { @@ -393,10 +367,6 @@ class IrSession(context: Context) { "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 @@ -408,12 +378,6 @@ class IrSession(context: Context) { ) // 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() @@ -444,11 +408,8 @@ class IrSession(context: Context) { fun stop() { if (!running.getAndSet(false)) return pipeline = null - // unblock a pending async read so the loop can exit and tear down - try { - streamRequest?.cancel() - } catch (_: Exception) { - } + // the stream loop exits within its 500 ms read timeout and owns the + // teardown (STOP -> close -> IDLE) } fun destroy() { diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 7b5c781..123a250 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:15062fbcc1e293b631d96f099c53ed00552d251e00a095ae4dde0f0c2d28b35f +oid sha256:4b67eee1946a2bf410958939d938a9b9e28a3ba48adc8d96afca18a86e93070a size 11873652 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index db0ad53..bc93330 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -271,6 +271,25 @@ (若官方也掉,就是供电/兼容问题,与我们的代码无关);③ 新日志看 `usb detached` 与 `get_status halted=` 行。 +## 用户反馈修复 第十四轮(2026-09-10,端点 halt 真凶确认:usbfs 超时遗留) + +- [x] **用户回传第二轮日志 + 关键信息"官方软件正常"**:每次 clear_halt 都成功 + (rc=0)但下一次读仍 -1 且 halted=1 → 不是相机 STALL,而是 **Linux/Android + usbfs 在 bulkTransfer 超时后把端点标记 halted,后续传输全部瞬间失败**。 + 流端点首次 500ms 读超时(相机 1~2s 才开始出流)→ 0x81 被标 halted → + 之后 19 秒的读全是瞬间失败,帧全被错过。66c/66f 响应读不到同机理 + (C 参考在 PC 上也忽略这些失败,完全吻合)。官方 App 正常 → 排除供电问题。 +- [x] **修复(核心一行)**:流循环每次 -1 后**无条件 GET_STATUS + CLEAR_HALT** + (节流日志),端点永远保持可用;相机 1~2 秒启动延迟期间的若干次超时 + 不再杀死数据流。去掉异步 UsbRequest(usbfs 超时对 async 同样留 halt, + 无收益);5 秒零数据自动重发一次 START 兜底;流内 FFC 响应读缩短为 + 400ms(不再阻塞渲染线程 2 秒)。 +- [x] 第 13 轮的会话互斥/DETACHED 恢复/去抖已验证生效(日志见 "stopping + stale previous session")。 +- [x] 构建+单测全过;APK 已更新(11.87MB)。 +- 预期:本轮装上后,日志应出现 `first reads [0] n=...` 与递增的 frames/rendered, + 画面出图。若 timeouts 持续上涨且 reads=0,再看 5s re-kick 与 halted 状态。 + ## 待办 - 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——进行中: