android: clear usbfs endpoint-halt after every timed-out transfer (stream killer); drop async reader, add START re-kick

This commit is contained in:
ZXCLI
2026-09-10 01:38:25 +08:00
parent 0f2aa11196
commit 1a2fc7abb8
3 changed files with 68 additions and 88 deletions
@@ -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() {