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.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() {
|
||||
|
||||
Reference in New Issue
Block a user