android: clear usbfs endpoint-halt after every timed-out transfer (stream killer); drop async reader, add START re-kick
This commit is contained in:
@@ -3,7 +3,6 @@ package com.mag160c.thermal.usb
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.usb.UsbDeviceConnection
|
import android.hardware.usb.UsbDeviceConnection
|
||||||
import android.hardware.usb.UsbEndpoint
|
import android.hardware.usb.UsbEndpoint
|
||||||
import android.hardware.usb.UsbRequest
|
|
||||||
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 com.mag160c.thermal.media.DebugLog
|
||||||
@@ -12,7 +11,6 @@ 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.nio.ByteBuffer
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
@@ -24,13 +22,16 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
* stop: STOP(74)
|
* stop: STOP(74)
|
||||||
* Sustained FFC(0)/FFC(1) commands are emitted by [RenderPipeline.onFfc].
|
* Sustained FFC(0)/FFC(1) commands are emitted by [RenderPipeline.onFfc].
|
||||||
*
|
*
|
||||||
* Round-13 hardening from real-device logs (vivo, EP silence + re-enum):
|
* Round-13/14 hardening from real-device logs (vivo V2509A, EP silence):
|
||||||
* - stream endpoint read via async UsbRequest (API 30+), sync fallback;
|
* - KEY FIX (round 14): Linux/Android usbfs marks an endpoint HALTED after
|
||||||
* - every failed transfer logs the endpoint status and tries
|
* a timed-out bulk transfer, and every later transfer then fails instantly
|
||||||
* CLEAR_FEATURE(HALT) once, then retries (Android never clears halts);
|
* 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
|
* - only ONE session may own the camera (a second claimInterface steals
|
||||||
* the interface from the first and both die);
|
* 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) {
|
class IrSession(context: Context) {
|
||||||
data class CameraIdentity(
|
data class CameraIdentity(
|
||||||
@@ -68,10 +69,6 @@ class IrSession(context: Context) {
|
|||||||
private var streaming = false
|
private var streaming = false
|
||||||
private var identity = CameraIdentity(1, 0, 160, 120, 15)
|
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?) {
|
fun setListener(l: Listener?) {
|
||||||
listener = l
|
listener = l
|
||||||
}
|
}
|
||||||
@@ -153,7 +150,7 @@ class IrSession(context: Context) {
|
|||||||
val pipe = RenderPipeline(
|
val pipe = RenderPipeline(
|
||||||
w = identity.width, h = identity.height,
|
w = identity.width, h = identity.height,
|
||||||
onFfc = { param ->
|
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)) {
|
if (!pipe.loadDdt(ddtBytes)) {
|
||||||
@@ -199,19 +196,22 @@ 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)
|
||||||
|
|
||||||
/** Log endpoint status + clear a possible halt; returns clear rc. */
|
/** Log endpoint status + clear a possible halt; returns clear rc.
|
||||||
private fun diagnoseEndpoint(conn: UsbDeviceConnection, epAddr: Int) {
|
* [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 st = ByteArray(2)
|
||||||
val src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100)
|
val src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100)
|
||||||
val halted = if (src == 2) (st[0].toInt() and 0x01) else -1
|
val halted = if (src == 2) (st[0].toInt() and 0x01) else -1
|
||||||
val clr = conn.controlTransfer(0x02, 1, 0, epAddr, null, 0, 100)
|
val clr = conn.controlTransfer(0x02, 1, 0, epAddr, null, 0, 100)
|
||||||
|
if (failureCount <= 5 || failureCount % 100 == 0) {
|
||||||
DebugLog.log(
|
DebugLog.log(
|
||||||
"usb",
|
"usb",
|
||||||
"ep 0x%02X get_status rc=%d halted=%d clear_halt rc=%d".format(
|
"ep 0x%02X fail#$failureCount get_status rc=%d halted=%d clear_halt rc=%d".format(
|
||||||
Locale.US, epAddr, src, halted, clr,
|
Locale.US, epAddr, src, halted, clr,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun sendCmd(
|
private fun sendCmd(
|
||||||
packet: ByteArray,
|
packet: ByteArray,
|
||||||
@@ -283,27 +283,7 @@ 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)
|
||||||
|
DebugLog.log("stream", "reader loop start: sync bulkTransfer (500 ms) + halt recovery")
|
||||||
// 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 readCount = 0
|
||||||
var frameCount = 0
|
var frameCount = 0
|
||||||
@@ -313,42 +293,36 @@ class IrSession(context: Context) {
|
|||||||
var lastLog = t0
|
var lastLog = t0
|
||||||
var firstReads = 0
|
var firstReads = 0
|
||||||
var noDataNotified = false
|
var noDataNotified = false
|
||||||
|
var rekicked = false
|
||||||
|
|
||||||
while (running.get()) {
|
while (running.get()) {
|
||||||
val n: Int
|
val n = conn.bulkTransfer(epStream, tmp, tmp.size, 500)
|
||||||
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) {
|
if (n <= 0) {
|
||||||
timeouts++
|
timeouts++
|
||||||
continue
|
// 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)
|
||||||
}
|
}
|
||||||
} else {
|
if (readCount == 0 && now - t0 > 10000 && !noDataNotified) {
|
||||||
n = conn.bulkTransfer(epStream, tmp, tmp.size, 500)
|
noDataNotified = true
|
||||||
if (n <= 0) {
|
notify(State.STREAMING, "no_stream_data")
|
||||||
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++
|
readCount++
|
||||||
if (firstReads < 3) {
|
if (firstReads < 3) {
|
||||||
@@ -393,10 +367,6 @@ class IrSession(context: Context) {
|
|||||||
"renderState=${pipe.frameIndex()} ref=${pipe.hasReference()}",
|
"renderState=${pipe.frameIndex()} ref=${pipe.hasReference()}",
|
||||||
)
|
)
|
||||||
lastLog = now
|
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
|
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,
|
// teardown owned HERE (stop() only flips the flag): STOP then close,
|
||||||
// so the STOP actually reaches the camera
|
// so the STOP actually reaches the camera
|
||||||
val stopReq = streamRequest
|
|
||||||
streamRequest = null
|
|
||||||
try {
|
|
||||||
stopReq?.cancel()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
if (active === this) active = null
|
if (active === this) active = null
|
||||||
sendCmd(cmd4(MagProtocol.CMD_STOP), epOut, epResp, "STOP")
|
sendCmd(cmd4(MagProtocol.CMD_STOP), epOut, epResp, "STOP")
|
||||||
transport.close()
|
transport.close()
|
||||||
@@ -444,11 +408,8 @@ class IrSession(context: Context) {
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
if (!running.getAndSet(false)) return
|
if (!running.getAndSet(false)) return
|
||||||
pipeline = null
|
pipeline = null
|
||||||
// unblock a pending async read so the loop can exit and tear down
|
// the stream loop exits within its 500 ms read timeout and owns the
|
||||||
try {
|
// teardown (STOP -> close -> IDLE)
|
||||||
streamRequest?.cancel()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
|
|||||||
Binary file not shown.
@@ -271,6 +271,25 @@
|
|||||||
(若官方也掉,就是供电/兼容问题,与我们的代码无关);③ 新日志看
|
(若官方也掉,就是供电/兼容问题,与我们的代码无关);③ 新日志看
|
||||||
`usb detached` 与 `get_status halted=` 行。
|
`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保存端到端)——进行中:
|
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——进行中:
|
||||||
|
|||||||
Reference in New Issue
Block a user