android: port official MAG-Cx UsbCommunication handshake verbatim (66b/66c/66f 800ms, cali file fetch via 670+EP0x84, BasePara parse)
This commit is contained in:
@@ -296,6 +296,7 @@ private fun statusText(state: LiveViewModel.LiveState): String = when (state.sta
|
||||
"open_fail" -> "USB打开失败(查看调试日志)"
|
||||
"no_endpoints" -> "未找到数据端点(查看调试日志)"
|
||||
"connect_fail" -> "连接流程异常(查看调试日志)"
|
||||
"no_handshake" -> "相机无应答,请拔插热像仪重试"
|
||||
"no_stream_data" -> "已连接但无数据流(10秒),日志已记录"
|
||||
else -> {
|
||||
val prefix = if (state.status.startsWith("exception:")) {
|
||||
|
||||
@@ -11,30 +11,32 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
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, responses may be absent on phones)
|
||||
* start: 50 ms -> FFC(0) x2 -> 300 ms -> START(73)
|
||||
* stop: STOP(74)
|
||||
* Sustained FFC(0)/FFC(1) commands are emitted by [RenderPipeline.onFfc].
|
||||
* Live IR camera session.
|
||||
*
|
||||
* Round-15 BREAKTHROUGH (decompiled the OFFICIAL Android libcoresdk that
|
||||
* streams on this very phone — CNetComm functions in analysis/ida/export):
|
||||
* - the official connect flow is 66f (cali size/version) -> 670 + EP 0x84
|
||||
* bulk fetch of the calibration file when the local cache misses
|
||||
* ("First running on new host...") -> reader thread -> 50 ms -> START;
|
||||
* - it NEVER sends 66b/66c at connect (66b is PC-demo legacy and wedges
|
||||
* this firmware: camera answers it once, then goes silent and watchdog-
|
||||
* reboots ~25 s later — exactly what our logs showed);
|
||||
* - no pre-start FFC either: RenderPipeline.onFfc drives the FFC cadence
|
||||
* once frames flow.
|
||||
* Plus round-13/14 hardening: single session owner, CLEAR_HALT after every
|
||||
* failed transfer (usbfs marks endpoints halted after a timed-out transfer),
|
||||
* 5 s START re-kick, DETACHED handling.
|
||||
* Round 16: the connect sequence is now a FAITHFUL port of the official
|
||||
* MAG-Cx app's UsbCommunication.java (jadx-decompiled; the code that streams
|
||||
* on this very phone):
|
||||
*
|
||||
* 1. GetParameter1 (66b, 4B) -> read 0x5BB5B55B BasePara1 (60 B)
|
||||
* {serial, hw+devType, sw, width, height, fps, gain, flip, ...}
|
||||
* 2. GetParameter2 (66c, 4B) -> read 0x5BB5B55C BasePara2
|
||||
* 3. GetCaliInfo (66f, 4B) -> read 0x5BB5B55E {size, res, date}
|
||||
* 4. cache miss -> GetCaliFile (670) -> ack 0x5BB5B55D -> read `size` bytes
|
||||
* of the calibration file from EP 0x84 in 16 KB chunks (the native
|
||||
* renderer startProcess() requires the cali file path)
|
||||
* 5. native startProcess(caliPath) == our RenderPipeline.loadDdt(bytes)
|
||||
* 6. reader thread -> 5 ms -> StartTransferImg (673)
|
||||
*
|
||||
* Timeouts are 800 ms everywhere (official TIMEOUT constant). Earlier rounds
|
||||
* used 400 ms reads and skipped the cali handshake entirely — the camera
|
||||
* ignored everything and watchdog-rebooted.
|
||||
* Kept hardening: single session owner, CLEAR_HALT after failed transfers
|
||||
* (usbfs marks endpoints halted after a timed-out transfer), DETACHED release.
|
||||
*/
|
||||
class IrSession(context: Context) {
|
||||
data class CameraIdentity(
|
||||
@@ -62,6 +64,16 @@ class IrSession(context: Context) {
|
||||
/** The single session allowed to own the camera across view models. */
|
||||
@Volatile
|
||||
private var active: IrSession? = null
|
||||
|
||||
/** Official UsbCommunication.TIMEOUT. */
|
||||
private const val TIMEOUT_MS = 800
|
||||
|
||||
/** Official MAX_RECEIVED_LEN: all bulk reads use 16 KB chunks. */
|
||||
private const val CHUNK_SIZE = 16384
|
||||
|
||||
private const val MIN_CALI_LEN = 65536
|
||||
private const val MAX_CALI_LEN = 104857600
|
||||
private const val CALI_NO_DATA_LIMIT_MS = 5000
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -73,10 +85,6 @@ class IrSession(context: Context) {
|
||||
private var streaming = false
|
||||
private var identity = CameraIdentity(1, 0, 160, 120, 15)
|
||||
|
||||
/** Cali-file size/version reported by 66f/670 (0x5BB5B55E pair). */
|
||||
private var caliSize = 0L
|
||||
private var caliVersion = 0L
|
||||
|
||||
fun setListener(l: Listener?) {
|
||||
listener = l
|
||||
}
|
||||
@@ -92,11 +100,12 @@ class IrSession(context: Context) {
|
||||
var lastRawFrame: ByteArray? = null
|
||||
private set
|
||||
|
||||
/** Cached 66b/66c camera info blocks (0x38B each) for the MDT DDT section. */
|
||||
/** Cached BasePara1 block (0x38B) for the MDT DDT section. */
|
||||
@Volatile
|
||||
var lastInfo0: ByteArray? = null
|
||||
private set
|
||||
|
||||
/** Cached 66c parameter block for the MDT DDT section. */
|
||||
@Volatile
|
||||
var lastInfo1: ByteArray? = null
|
||||
private set
|
||||
@@ -119,7 +128,7 @@ class IrSession(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startInternal(ddtBytes: ByteArray) {
|
||||
private fun startInternal(bundledDdt: 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
|
||||
@@ -151,111 +160,189 @@ class IrSession(context: Context) {
|
||||
}
|
||||
val (epOut, epResp, epStream) = transport.endpoints()
|
||||
val ep84 = transport.endpointByAddress(UsbTransport.EP_BULK_IN)
|
||||
if (ep84 != null) {
|
||||
DebugLog.log("session", "cali endpoint 0x84 available")
|
||||
}
|
||||
if (epOut == null || epResp == null || epStream == null) {
|
||||
transport.close()
|
||||
notify(State.ERROR, "no_endpoints")
|
||||
return
|
||||
}
|
||||
val conn = transport.connection()
|
||||
if (conn == null) {
|
||||
notify(State.ERROR, "open_fail")
|
||||
return
|
||||
}
|
||||
|
||||
// ---- official UsbCommunication.connect() ----
|
||||
// 1) GetParameter1 -> BasePara1 (mandatory; official aborts on miss)
|
||||
if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_PARAMETER1), "GetParameter1")) {
|
||||
transport.close()
|
||||
notify(State.ERROR, "no_handshake")
|
||||
return
|
||||
}
|
||||
val r1 = readResp(conn, epResp, "GetParameter1")
|
||||
if (r1 == null || r1.second != MagProtocol.RSP_SEND_PARAMETER1 || r1.first < 60) {
|
||||
DebugLog.log("session", "parameter1 handshake failed -> abort (official aborts too)")
|
||||
transport.close()
|
||||
notify(State.ERROR, "no_handshake")
|
||||
return
|
||||
}
|
||||
parseBasePara1(r1.third)
|
||||
DebugLog.log("session", "identity $identity")
|
||||
|
||||
// 2) GetParameter2 -> BasePara2 (log only)
|
||||
writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_PARAMETER2), "GetParameter2")
|
||||
val r2 = readResp(conn, epResp, "GetParameter2")
|
||||
if (r2 != null && r2.second == MagProtocol.RSP_SEND_PARAMETER2 && r2.first >= 4) {
|
||||
lastInfo1 = r2.third.copyOf()
|
||||
}
|
||||
|
||||
// 3) GetCaliInfo -> {size, reserved, date}
|
||||
writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_CALI_INFO), "GetCaliInfo")
|
||||
val r3 = readResp(conn, epResp, "GetCaliInfo")
|
||||
var caliSize = 0L
|
||||
var caliDate = 0L
|
||||
if (r3 != null && r3.second == MagProtocol.RSP_SEND_CALI_INFO && r3.first >= 20) {
|
||||
caliSize = MagProtocol.u32(r3.third, 0).toLong()
|
||||
caliDate = (MagProtocol.u32(r3.third, 8).toLong() and 0xFFFFFFFFL) or
|
||||
((MagProtocol.u32(r3.third, 12).toLong() and 0xFFFFFFFFL) shl 32)
|
||||
}
|
||||
DebugLog.log("session", "cali info: size=$caliSize date=$caliDate")
|
||||
|
||||
// 4) calibration file: cache hit or fetch from EP 0x84
|
||||
val ddt = obtainCali(conn, ep84, epOut, epResp, caliSize, caliDate, bundledDdt)
|
||||
DebugLog.log("session", "ddt bytes ${ddt.size}")
|
||||
|
||||
// 5) native startProcess(caliPath) == pipeline + loadDdt
|
||||
val pipe = RenderPipeline(
|
||||
w = identity.width, h = identity.height,
|
||||
onFfc = { param ->
|
||||
sendCmd(cmd8(MagProtocol.CMD_FFC, param), epOut, epResp, "FFC($param)", 400)
|
||||
sendCmd(
|
||||
MagProtocol.cmd8(MagProtocol.CMD_SET_SHUTTER_STATE, param),
|
||||
epOut, epResp, "FFC($param)",
|
||||
)
|
||||
},
|
||||
)
|
||||
if (!pipe.loadDdt(ddtBytes)) {
|
||||
DebugLog.log("session", "ddt_fail size=${ddtBytes.size}")
|
||||
transport.close()
|
||||
notify(State.ERROR, "ddt_fail")
|
||||
return
|
||||
if (!pipe.loadDdt(ddt)) {
|
||||
DebugLog.log("session", "ddt_fail (fetched/bundled ${ddt.size} B not loadable)")
|
||||
if (ddt !== bundledDdt && !pipe.loadDdt(bundledDdt)) {
|
||||
transport.close()
|
||||
notify(State.ERROR, "ddt_fail")
|
||||
return
|
||||
}
|
||||
}
|
||||
DebugLog.log("session", "ddt bundled ${ddtBytes.size} bytes")
|
||||
pipeline = pipe
|
||||
active = this
|
||||
|
||||
// Startup sequence EXACTLY as the official Android libcoresdk does it
|
||||
// (CNetComm decompilation — the code that streams on this phone):
|
||||
// 1. 66f -> 0x5BB5B55E pair {cali size, version} (ReadCaliInfo)
|
||||
// 2. cache missing -> 670 -> pair -> read the file from EP 0x84
|
||||
// ("First running on new host, it will cost some time ...")
|
||||
// 3. reader thread -> 50 ms -> START(73). NO 66b (PC-demo legacy,
|
||||
// it wedges this firmware), NO pre-start FFC (pipeline drives it).
|
||||
val prep = sendCmd(cmd4(MagProtocol.CMD_GET_INFO), epOut, epResp, "66f cali-info", 1000)
|
||||
DebugLog.log("session", "cali size=$caliSize version=$caliVersion ok=${prep.ok}")
|
||||
val ddt = prepareDdtBytes(ep84, ddtBytes)
|
||||
if (!pipe.loadDdt(ddt)) {
|
||||
DebugLog.log("session", "ddt_fail size=${ddt.size}")
|
||||
transport.close()
|
||||
notify(State.ERROR, "ddt_fail")
|
||||
return
|
||||
}
|
||||
DebugLog.log("session", "ddt loaded ${ddt.size} bytes")
|
||||
// 6) start reader loop, then StartTransferImg (official: threads -> 5 ms -> 673)
|
||||
notify(State.STREAMING, null)
|
||||
notifyIdentity()
|
||||
DebugLog.log("session", "identity $identity")
|
||||
|
||||
running.set(true)
|
||||
Thread.sleep(50)
|
||||
sendCmd(cmd4(MagProtocol.CMD_START), epOut, epResp, "START", 400)
|
||||
if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_START_TRANSFER_IMG), "StartTransferImg")) {
|
||||
DebugLog.log("session", "START write failed")
|
||||
}
|
||||
readResp(conn, epResp, "StartTransferImg ack")
|
||||
streamLoop(pipe, epStream, epOut, epResp)
|
||||
}
|
||||
|
||||
/** Parse BasePara1 (0x5BB5B55B payload, 56 B) into [identity] + lastInfo0. */
|
||||
private fun parseBasePara1(payload: ByteArray) {
|
||||
val serial = MagProtocol.u32(payload, 0)
|
||||
val hwDev = MagProtocol.u32(payload, 4)
|
||||
val devType = hwDev ushr 24
|
||||
val width = MagProtocol.u32(payload, 16)
|
||||
val height = MagProtocol.u32(payload, 20)
|
||||
val fps = MagProtocol.u32(payload, 24)
|
||||
identity = CameraIdentity(
|
||||
pid = devType,
|
||||
serial = serial.toLong() and 0xFFFFFFFFL,
|
||||
width = if (width in 1..65535) width else 160,
|
||||
height = if (height in 1..65535) height else 120,
|
||||
fps = if (fps in 1..200) fps else 15,
|
||||
)
|
||||
lastInfo0 = payload.copyOf(minOf(0x38, payload.size))
|
||||
DebugLog.log(
|
||||
"cmd",
|
||||
"BasePara1: serial=$serial devType=$devType ${width}x$height @${fps}fps",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Official cali-file handshake: 66f reported size/version; if the local
|
||||
* cache doesn't match, fetch the file from EP 0x84 (670 + bulk reads) and
|
||||
* cache it. Returns the bytes to feed RenderPipeline.loadDdt — the
|
||||
* camera's own calibration when available, else the bundled DDT.
|
||||
* Official cali-file handling (RefreshCaliFileNeeded + GetCaliFile +
|
||||
* ThreadCaliRecv): cache name {product}.{serial}.{date}; on miss send
|
||||
* GetCaliFile (670), consume its ack on 0x82, then read the file from
|
||||
* EP 0x84 in 16 KB chunks. Returns the bytes to loadDdt, falling back
|
||||
* to the bundled DDT when anything fails.
|
||||
*/
|
||||
private fun prepareDdtBytes(ep84: UsbEndpoint?, bundled: ByteArray): ByteArray {
|
||||
if (caliSize <= 0 || caliSize > 0x6400000L) {
|
||||
DebugLog.log("session", "cali size invalid -> using bundled DDT")
|
||||
private fun obtainCali(
|
||||
conn: UsbDeviceConnection,
|
||||
ep84: UsbEndpoint?,
|
||||
epOut: UsbEndpoint,
|
||||
epResp: UsbEndpoint,
|
||||
size: Long,
|
||||
date: Long,
|
||||
bundled: ByteArray,
|
||||
): ByteArray {
|
||||
if (size < MIN_CALI_LEN || size > MAX_CALI_LEN) {
|
||||
DebugLog.log("session", "cali size invalid ($size) -> bundled DDT")
|
||||
return bundled
|
||||
}
|
||||
val conn = transport.connection() ?: return bundled
|
||||
val cache = java.io.File(
|
||||
java.io.File(appContext.filesDir, "cali"),
|
||||
"magcore.cali.$caliVersion",
|
||||
)
|
||||
if (cache.isFile && cache.length() == caliSize) {
|
||||
DebugLog.log("session", "cali cache hit: ${cache.name} (${cache.length()} B)")
|
||||
val product = when (identity.pid) {
|
||||
0 -> "c1"; 1 -> "c3"; 2 -> "c3p"; 3 -> "core160"; 5 -> "c1pro"; 6 -> "c1prolite"
|
||||
else -> "unknown"
|
||||
}
|
||||
val cache = File(File(appContext.filesDir, "cali"), "$product.${identity.serial}.$date")
|
||||
if (cache.isFile && cache.length() == size) {
|
||||
DebugLog.log("session", "cali cache hit: ${cache.name}")
|
||||
return try {
|
||||
cache.readBytes()
|
||||
} catch (e: Exception) {
|
||||
DebugLog.log("session", "cache read failed -> bundled: $e")
|
||||
DebugLog.log("session", "cali cache read failed -> bundled: $e")
|
||||
bundled
|
||||
}
|
||||
}
|
||||
if (ep84 == null) {
|
||||
DebugLog.log("session", "EP 0x84 missing -> using bundled DDT")
|
||||
DebugLog.log("session", "EP 0x84 missing -> bundled DDT")
|
||||
return bundled
|
||||
}
|
||||
DebugLog.log(
|
||||
"session",
|
||||
"first run on this host: fetching cali file $caliSize B from EP 0x84",
|
||||
"first run on this host: downloading cali file $size B (EP 0x84)",
|
||||
)
|
||||
val out = ByteArray(caliSize.toInt())
|
||||
val buf = ByteArray(0x80000)
|
||||
// official: sendEmptyCmd(GetCaliFile) — its ack (0x5BB5B55D) arrives on 0x82
|
||||
if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_CALI_FILE), "GetCaliFile")) {
|
||||
DebugLog.log("session", "GetCaliFile write failed -> bundled DDT")
|
||||
return bundled
|
||||
}
|
||||
readResp(conn, epResp, "GetCaliFile ack")
|
||||
// ThreadCaliRecv: read `size` bytes in 16 KB chunks (800 ms per read,
|
||||
// abort after 5 s without progress)
|
||||
val out = ByteArray(size.toInt())
|
||||
val chunk = ByteArray(CHUNK_SIZE)
|
||||
var got = 0
|
||||
var silentMs = 0
|
||||
while (got < out.size) {
|
||||
val want = minOf(buf.size, out.size - got)
|
||||
val n = conn.bulkTransfer(ep84, buf, want, 60000)
|
||||
val want = minOf(chunk.size, out.size - got)
|
||||
val n = conn.bulkTransfer(ep84, chunk, want, TIMEOUT_MS)
|
||||
if (n <= 0) {
|
||||
diagnoseEndpoint(conn, ep84.address, got)
|
||||
DebugLog.log("session", "cali read failed at $got/${out.size} -> bundled DDT")
|
||||
return bundled
|
||||
silentMs += TIMEOUT_MS
|
||||
diagnoseEndpoint(conn, ep84.address, silentMs / TIMEOUT_MS)
|
||||
if (silentMs >= CALI_NO_DATA_LIMIT_MS) {
|
||||
DebugLog.log("session", "cali download stalled at $got/${out.size} -> bundled DDT")
|
||||
return bundled
|
||||
}
|
||||
continue
|
||||
}
|
||||
System.arraycopy(buf, 0, out, got, n)
|
||||
silentMs = 0
|
||||
System.arraycopy(chunk, 0, out, got, n)
|
||||
got += n
|
||||
if (got % 0x100000L < 0x80000L) {
|
||||
DebugLog.log("session", "cali fetch progress $got/${out.size}")
|
||||
if (got % 0x40000 < CHUNK_SIZE) {
|
||||
DebugLog.log("session", "cali download ${got * 100 / out.size}%")
|
||||
}
|
||||
}
|
||||
try {
|
||||
cache.parentFile?.mkdirs()
|
||||
cache.writeBytes(out)
|
||||
DebugLog.log("session", "cali fetched+cached: ${cache.name} ($got B)")
|
||||
DebugLog.log("session", "cali downloaded+cached: ${cache.name} ($got B)")
|
||||
} catch (e: Exception) {
|
||||
DebugLog.log("session", "cali cache write failed (using bytes anyway): $e")
|
||||
}
|
||||
@@ -271,11 +358,7 @@ class IrSession(context: Context) {
|
||||
listener?.onIdentity(identitySnapshot())
|
||||
}
|
||||
|
||||
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.
|
||||
* [failureCount] throttles logging in hot loops (first 5, then 1/100). */
|
||||
/** Log endpoint status + clear a possible halt (usbfs timeout artifact). */
|
||||
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)
|
||||
@@ -291,69 +374,60 @@ class IrSession(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
var written = conn.bulkTransfer(out, packet, packet.size, 500)
|
||||
if (written != packet.size) {
|
||||
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, respTimeoutMs)
|
||||
/** Official sendEmptyCmd/sendCmd: bulk write on EP 0x03 (800 ms). */
|
||||
private fun writeCmd(conn: UsbDeviceConnection, out: UsbEndpoint, packet: ByteArray, name: String): Boolean {
|
||||
val n = conn.bulkTransfer(out, packet, packet.size, TIMEOUT_MS)
|
||||
DebugLog.log("cmd", "$name write=$n/${packet.size}")
|
||||
return n == packet.size
|
||||
}
|
||||
|
||||
/** Official recvCmd: bulk read on EP 0x82, up to 64 B (800 ms). */
|
||||
private fun readResp(conn: UsbDeviceConnection, resp: UsbEndpoint, name: String): Triple<Int, Int, ByteArray>? {
|
||||
val buf = ByteArray(64)
|
||||
val n = conn.bulkTransfer(resp, buf, buf.size, TIMEOUT_MS)
|
||||
if (n <= 3) {
|
||||
diagnoseEndpoint(conn, resp.address)
|
||||
DebugLog.log("cmd", "$name read=$n bytes (EP 0x82, ${respTimeoutMs} ms)")
|
||||
return CmdResult(written, n, 0)
|
||||
DebugLog.log("cmd", "$name resp=$n (timeout ${TIMEOUT_MS} ms)")
|
||||
diagnoseEndpoint(conn, resp.address, 0)
|
||||
return null
|
||||
}
|
||||
val magic = MagProtocol.u32(buf, 0)
|
||||
DebugLog.log(
|
||||
"cmd", "$name ok: rsp=0x%08X len=%d head=%s".format(
|
||||
"cmd", "$name resp=0x%08X len=%d head=%s".format(
|
||||
Locale.US, magic, n,
|
||||
buf.copyOfRange(4, minOf(n, 20)).joinToString(" ") {
|
||||
"%02X".format(Locale.US, it.toInt() and 0xFF)
|
||||
},
|
||||
),
|
||||
)
|
||||
if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) {
|
||||
lastInfo1 = buf.copyOfRange(4, 4 + 0x38)
|
||||
return Triple(n, magic, buf.copyOfRange(4, n))
|
||||
}
|
||||
|
||||
/** FFC / STOP style exchange: write then read the ack on 0x82. */
|
||||
private fun sendCmd(packet: ByteArray, out: UsbEndpoint, resp: UsbEndpoint, name: String): CmdResult {
|
||||
val conn: UsbDeviceConnection = transport.connection()
|
||||
?: run {
|
||||
DebugLog.log("cmd", "$name: no connection")
|
||||
return CmdResult(0, 0, 0)
|
||||
}
|
||||
var written = conn.bulkTransfer(out, packet, packet.size, TIMEOUT_MS)
|
||||
if (written != packet.size) {
|
||||
DebugLog.log("cmd", "$name write=$written/${packet.size} -> diagnose + retry")
|
||||
diagnoseEndpoint(conn, out.address)
|
||||
written = conn.bulkTransfer(out, packet, packet.size, TIMEOUT_MS)
|
||||
if (written != packet.size) {
|
||||
DebugLog.log("cmd", "$name retry write=$written (EP dead)")
|
||||
return CmdResult(written, 0, 0)
|
||||
}
|
||||
}
|
||||
if (magic == MagProtocol.RSP_PAIR && n >= 20) {
|
||||
// 0x5BB5B55E: {u64 cali size, u64 cali version} (DecodeCmd)
|
||||
caliSize = (MagProtocol.u32(buf, 4).toLong() and 0xFFFFFFFFL) or
|
||||
((MagProtocol.u32(buf, 8).toLong() and 0xFFFFFFFFL) shl 32)
|
||||
caliVersion = (MagProtocol.u32(buf, 12).toLong() and 0xFFFFFFFFL) or
|
||||
((MagProtocol.u32(buf, 16).toLong() and 0xFFFFFFFFL) shl 32)
|
||||
DebugLog.log("cmd", "cali pair: size=$caliSize version=$caliVersion")
|
||||
}
|
||||
if (magic == MagProtocol.RSP_INFO_0 && n >= 0x3C) {
|
||||
val payload = buf.copyOfRange(4, n)
|
||||
lastInfo0 = payload.copyOf(0x38)
|
||||
val newIdentity = CameraIdentity(
|
||||
pid = MagProtocol.u32(payload, 0),
|
||||
serial = (MagProtocol.u32(payload, 8).toLong() and 0xFFFFFFFFL) or
|
||||
((MagProtocol.u32(payload, 12).toLong() and 0xFFFFFFFFL) shl 32),
|
||||
width = MagProtocol.u32(payload, 0x10),
|
||||
height = MagProtocol.u32(payload, 0x14),
|
||||
fps = if (payload.size >= 0x1C) MagProtocol.u32(payload, 0x18) else identity.fps,
|
||||
)
|
||||
identity = newIdentity
|
||||
DebugLog.log("cmd", "camera info: $newIdentity")
|
||||
val buf = ByteArray(64)
|
||||
val n = conn.bulkTransfer(resp, buf, buf.size, TIMEOUT_MS)
|
||||
if (n <= 3) {
|
||||
diagnoseEndpoint(conn, resp.address)
|
||||
DebugLog.log("cmd", "$name ack=$n bytes")
|
||||
return CmdResult(written, n, 0)
|
||||
}
|
||||
val magic = MagProtocol.u32(buf, 0)
|
||||
DebugLog.log("cmd", "$name ack=0x%08X len=%d".format(Locale.US, magic, n))
|
||||
return CmdResult(written, n, magic)
|
||||
}
|
||||
|
||||
@@ -369,7 +443,7 @@ class IrSession(context: Context) {
|
||||
val out = IntArray(320 * 240)
|
||||
val tmp = ByteArray(0x8000)
|
||||
val noop = ByteArray(0)
|
||||
DebugLog.log("stream", "reader loop start: sync bulkTransfer (500 ms) + halt recovery")
|
||||
DebugLog.log("stream", "reader loop start: sync bulkTransfer (${TIMEOUT_MS} ms) + halt recovery")
|
||||
|
||||
var readCount = 0
|
||||
var frameCount = 0
|
||||
@@ -379,23 +453,15 @@ class IrSession(context: Context) {
|
||||
var lastLog = t0
|
||||
var firstReads = 0
|
||||
var noDataNotified = false
|
||||
var rekicked = false
|
||||
|
||||
while (running.get()) {
|
||||
val n = conn.bulkTransfer(epStream, tmp, tmp.size, 500)
|
||||
val n = conn.bulkTransfer(epStream, tmp, tmp.size, TIMEOUT_MS)
|
||||
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 (readCount == 0 && now - t0 > 10000 && !noDataNotified) {
|
||||
noDataNotified = true
|
||||
notify(State.STREAMING, "no_stream_data")
|
||||
@@ -411,11 +477,6 @@ class IrSession(context: Context) {
|
||||
continue
|
||||
}
|
||||
readCount++
|
||||
// deferred camera-info fetch (MDT needs the 0x5BB5B55B block; the
|
||||
// official Android sequence doesn't send 66b during connect)
|
||||
if (readCount == 1 && lastInfo0 == null) {
|
||||
sendCmd(cmd4(MagProtocol.CMD_PREPARE1), epOut, epResp, "66b info (deferred)", 400)
|
||||
}
|
||||
if (firstReads < 3) {
|
||||
DebugLog.log(
|
||||
"stream", "first reads [$firstReads] n=$n head=%s".format(
|
||||
@@ -470,7 +531,7 @@ class IrSession(context: Context) {
|
||||
// teardown owned HERE (stop() only flips the flag): STOP then close,
|
||||
// so the STOP actually reaches the camera
|
||||
if (active === this) active = null
|
||||
sendCmd(cmd4(MagProtocol.CMD_STOP), epOut, epResp, "STOP")
|
||||
sendCmd(MagProtocol.cmd4(MagProtocol.CMD_STOP_TRANSFER_IMG), epOut, epResp, "StopTransferImg")
|
||||
transport.close()
|
||||
notify(State.IDLE, null)
|
||||
}
|
||||
@@ -499,8 +560,8 @@ class IrSession(context: Context) {
|
||||
fun stop() {
|
||||
if (!running.getAndSet(false)) return
|
||||
pipeline = null
|
||||
// the stream loop exits within its 500 ms read timeout and owns the
|
||||
// teardown (STOP -> close -> IDLE)
|
||||
// the stream loop exits within its read timeout and owns the teardown
|
||||
// (StopTransferImg -> close -> IDLE)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
|
||||
@@ -3,27 +3,35 @@ package com.mag160c.thermal.usb
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Vendor command/response protocol, recovered in analysis/protocol_spec.md
|
||||
* + the official Android libcoresdk decompilation (CNetComm).
|
||||
* Plain commands are 4-byte {magic}; FFC and parameter setters carry
|
||||
* 8-byte {magic, param}. Responses on EP 0x82:
|
||||
* 0x5BB5B55B camera info (0x38), 0x5BB5B55C parameter block (0x38),
|
||||
* 0x5BB5B55E/55F pair {u64 cali size, u64 cali version}.
|
||||
* 0x6BB6B670 (GetCaliFile) makes the camera push its calibration file on
|
||||
* EP 0x84 ("First running on new host, it will cost some time...").
|
||||
* Vendor command/response protocol — VERBATIM from the official MAG-Cx app's
|
||||
* Java source (sdk/UsbCommunication.java + P2DCmd.java/D2PCmd.java, jadx).
|
||||
* All commands are 4-byte little-endian {magic} on EP 0x03; FFC/laser/frame-
|
||||
* rate carry parameters (8/12 bytes). Timeouts are 800 ms everywhere.
|
||||
* Responses on EP 0x82 (D2P_*):
|
||||
* 0x5BB5B55B SendParameter1 (BasePara1: serial, hw+devType, sw, w, h, fps)
|
||||
* 0x5BB5B55C SendParameter2 (BasePara2)
|
||||
* 0x5BB5B55D SendCaliFile (ack for GetCaliFile)
|
||||
* 0x5BB5B55E SendCaliInfo {i32 size, i32 reserved, i64 date}
|
||||
* The calibration file itself is pushed on EP 0x84 in 16 KB chunks after
|
||||
* P2D_GetCaliFile (0x6BB6B670).
|
||||
*/
|
||||
object MagProtocol {
|
||||
const val CMD_PREPARE1 = 0x6BB6B66B
|
||||
const val CMD_PREPARE2 = 0x6BB6B66C
|
||||
const val CMD_GET_INFO = 0x6BB6B66F
|
||||
const val CMD_GET_CALI = 0x6BB6B670
|
||||
const val CMD_FFC = 0x6BB6B672
|
||||
const val CMD_START = 0x6BB6B673
|
||||
const val CMD_STOP = 0x6BB6B674
|
||||
const val CMD_GET_PARAMETER1 = 0x6BB6B66B
|
||||
const val CMD_GET_PARAMETER2 = 0x6BB6B66C
|
||||
const val CMD_SET_PARAMETER1 = 0x6BB6B66D
|
||||
const val CMD_SET_PARAMETER2 = 0x6BB6B66E
|
||||
const val CMD_GET_CALI_INFO = 0x6BB6B66F
|
||||
const val CMD_GET_CALI_FILE = 0x6BB6B670
|
||||
const val CMD_SEND_CALI_FILE = 0x6BB6B671
|
||||
const val CMD_SET_SHUTTER_STATE = 0x6BB6B672
|
||||
const val CMD_START_TRANSFER_IMG = 0x6BB6B673
|
||||
const val CMD_STOP_TRANSFER_IMG = 0x6BB6B674
|
||||
const val CMD_GET_LIFETIME = 0x6BB6B675
|
||||
|
||||
const val RSP_INFO_0 = 0x5BB5B55B
|
||||
const val RSP_INFO_1 = 0x5BB5B55C
|
||||
const val RSP_PAIR = 0x5BB5B55E
|
||||
const val RSP_SEND_PARAMETER1 = 0x5BB5B55B
|
||||
const val RSP_SEND_PARAMETER2 = 0x5BB5B55C
|
||||
const val RSP_SEND_CALI_FILE = 0x5BB5B55D
|
||||
const val RSP_SEND_CALI_INFO = 0x5BB5B55E
|
||||
|
||||
fun cmd4(magic: Int): ByteArray {
|
||||
val b = ByteBuffer.allocate(4)
|
||||
|
||||
Binary file not shown.
@@ -316,6 +316,40 @@
|
||||
`first run on this host: fetching cali file...` → `cali fetched+cached` →
|
||||
`first reads [0] n=...` → frames 递增 → 出图。
|
||||
|
||||
## 用户反馈修复 第十六轮(2026-09-10,反编译官方 MAG-Cx Java 源码,按其逐行重写 USB 层)
|
||||
|
||||
- [x] **jadx 反编译官方普通版**(C:\Tools\jadx-1.5.1,产物 C:\Tools\jadx-out):
|
||||
`sdk/UsbCommunication.java` 就是能在这台手机上跑通的完整 USB 协议 Java 实现。
|
||||
- [x] **官方真实协议(P2DCmd/D2PCmd 常量全表)**:
|
||||
- P2D:66b=GetParameter1、66c=GetParameter2、66f=GetCaliInfo、670=GetCaliFile、
|
||||
671=SendCaliFile、672=SetShutterState(FFC)、673=StartTransferImg、
|
||||
674=StopTransferImg、675=GetLifeTime、676=SetLaserState、677=PowerSave、
|
||||
679=SetFrameRate;全部 4 字节小端(intToByteArray LE,排除字节序假设)。
|
||||
- D2P 响应:0x5BB5B55B=BasePara1(serial/hw+devType/sw/宽/高/fps/gain/flip/
|
||||
interFrame/interLine/gfid/gsk,14 int=56B+4=60B,与日志完全吻合)、
|
||||
0x5BB5B55C=BasePara2、0x5BB5B55D=SendCaliFile ack、0x5BB5B55E={i32 size,
|
||||
i32 reserved, i64 date}、0x5BB5B561=lifetime。
|
||||
- [x] **官方连接序列(UsbCommunication.connect + startTransfer)**:
|
||||
1) 66b(**不读 ack**,waitAck 对 GetParameter1/2/GetCaliInfo 是 default=true)
|
||||
→ recvCmd 64B/800ms → BasePara1;
|
||||
2) 66c → 64B/800ms → BasePara2;
|
||||
3) 66f → 64B/800ms → CaliInfo{size,reserved,date};
|
||||
4) 缓存 `{caliDir}/{productType}.{serial}.{date}`(devType3=core160)命中→
|
||||
直接 startTransfer;未命中→启动 ThreadCaliRecv(0x84、16KB 块、800ms/读、
|
||||
无数据 5s 放弃)+ 发 670(waitForCmdAck 读 ack)→ 收满后 startTransfer;
|
||||
5) startTransfer:native startProcess(…, caliPath)(= loadDdt 拉到的文件)
|
||||
→ setEX/ExtPara/放大倍率(native,非 USB)→ 起线程 → 5ms → 673(读 ack)。
|
||||
- **TIMEOUT=800ms 全线统一**(我们此前的 400ms 是响应缺失的直接嫌疑);
|
||||
- 图像帧尾校验 drop=type 0/1;FFC 触发=按 GetLifeTime 开机时间表(devType3
|
||||
走 imageStableCounter 分支)+ 双击/消息触发,均为 672。
|
||||
- [x] **IrSession 按上述逐行重写**(保留:会话互斥、CLEAR_HALT 恢复、DETACHED、
|
||||
no_stream_data 提示;去掉:5s re-kick、延迟 66b)。BasePara1 解析出 identity
|
||||
(serial/devType/宽/高/fps)+ lastInfo0;BasePara2 存 lastInfo1(MDT 用)。
|
||||
no_handshake 状态屏显"相机无应答,请拔插重试"。
|
||||
- [x] 构建+单测全过;APK 已更新(11.87MB)。
|
||||
- 预期日志:GetParameter1 resp=0x5BB5B55B len=60 → GetParameter2 → GetCaliInfo
|
||||
(cali size=…date=…)→ 下载(首次)或 cache hit → frames 递增。
|
||||
|
||||
## 待办
|
||||
|
||||
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——进行中:
|
||||
|
||||
Reference in New Issue
Block a user