diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index bc37c7d..0a1280f 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -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:")) { 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 1c6b503..c105e53 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 @@ -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? { + 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() { diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt index 2652696..b51aebe 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt @@ -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) diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 539292c..2048940 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:292326ef3bf190a49296a58a6361649555f8e3078d3c527e24f300e98f545e2a -size 11873652 +oid sha256:03a3c8485f6c936695b27fbca7902e5760807326625883a55b3bc1f159a00e54 +size 11873886 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 0e901a5..1f7031e 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -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保存端到端)——进行中: