android: match official libcoresdk handshake - 66f cali-info, 670+EP0x84 cali fetch w/ cache, drop legacy 66b/pre-start FFC
This commit is contained in:
@@ -22,16 +22,19 @@ 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/14 hardening from real-device logs (vivo V2509A, EP silence):
|
* Round-15 BREAKTHROUGH (decompiled the OFFICIAL Android libcoresdk that
|
||||||
* - KEY FIX (round 14): Linux/Android usbfs marks an endpoint HALTED after
|
* streams on this very phone — CNetComm functions in analysis/ida/export):
|
||||||
* a timed-out bulk transfer, and every later transfer then fails instantly
|
* - the official connect flow is 66f (cali size/version) -> 670 + EP 0x84
|
||||||
* with -1 until CLEAR_FEATURE(HALT). The first 500 ms stream read (the
|
* bulk fetch of the calibration file when the local cache misses
|
||||||
* camera needs 1-2 s to start sending) therefore killed the whole stream.
|
* ("First running on new host...") -> reader thread -> 50 ms -> START;
|
||||||
* Now EVERY failed transfer is followed by get_status + clear_halt.
|
* - it NEVER sends 66b/66c at connect (66b is PC-demo legacy and wedges
|
||||||
* - only ONE session may own the camera (a second claimInterface steals
|
* this firmware: camera answers it once, then goes silent and watchdog-
|
||||||
* the interface from the first and both die);
|
* reboots ~25 s later — exactly what our logs showed);
|
||||||
* - init command reads are short (responses are advisory, as in C);
|
* - no pre-start FFC either: RenderPipeline.onFfc drives the FFC cadence
|
||||||
* - zero stream bytes for 5 s -> one START re-kick; 10 s -> UI notice.
|
* 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.
|
||||||
*/
|
*/
|
||||||
class IrSession(context: Context) {
|
class IrSession(context: Context) {
|
||||||
data class CameraIdentity(
|
data class CameraIdentity(
|
||||||
@@ -62,6 +65,7 @@ class IrSession(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val appContext: Context = context.applicationContext
|
||||||
private val transport = UsbTransport(context)
|
private val transport = UsbTransport(context)
|
||||||
private var listener: Listener? = null
|
private var listener: Listener? = null
|
||||||
private var pipeline: RenderPipeline? = null
|
private var pipeline: RenderPipeline? = null
|
||||||
@@ -69,6 +73,10 @@ 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)
|
||||||
|
|
||||||
|
/** Cali-file size/version reported by 66f/670 (0x5BB5B55E pair). */
|
||||||
|
private var caliSize = 0L
|
||||||
|
private var caliVersion = 0L
|
||||||
|
|
||||||
fun setListener(l: Listener?) {
|
fun setListener(l: Listener?) {
|
||||||
listener = l
|
listener = l
|
||||||
}
|
}
|
||||||
@@ -142,6 +150,10 @@ class IrSession(context: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val (epOut, epResp, epStream) = transport.endpoints()
|
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) {
|
if (epOut == null || epResp == null || epStream == null) {
|
||||||
transport.close()
|
transport.close()
|
||||||
notify(State.ERROR, "no_endpoints")
|
notify(State.ERROR, "no_endpoints")
|
||||||
@@ -159,31 +171,97 @@ class IrSession(context: Context) {
|
|||||||
notify(State.ERROR, "ddt_fail")
|
notify(State.ERROR, "ddt_fail")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
DebugLog.log("session", "ddt loaded ${ddtBytes.size} bytes")
|
DebugLog.log("session", "ddt bundled ${ddtBytes.size} bytes")
|
||||||
pipeline = pipe
|
pipeline = pipe
|
||||||
active = this
|
active = this
|
||||||
|
|
||||||
// prepare sequence (verified hardware: 4-byte commands; responses
|
// Startup sequence EXACTLY as the official Android libcoresdk does it
|
||||||
// advisory — the C reference ignores them, so short reads here)
|
// (CNetComm decompilation — the code that streams on this phone):
|
||||||
sendCmd(cmd4(MagProtocol.CMD_PREPARE1), epOut, epResp, "66b", 400)
|
// 1. 66f -> 0x5BB5B55E pair {cali size, version} (ReadCaliInfo)
|
||||||
sendCmd(cmd4(MagProtocol.CMD_PREPARE2), epOut, epResp, "66c", 400)
|
// 2. cache missing -> 670 -> pair -> read the file from EP 0x84
|
||||||
sendCmd(cmd4(MagProtocol.CMD_GET_INFO), epOut, epResp, "66f", 400)
|
// ("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")
|
||||||
notify(State.STREAMING, null)
|
notify(State.STREAMING, null)
|
||||||
notifyIdentity()
|
notifyIdentity()
|
||||||
DebugLog.log("session", "identity $identity")
|
DebugLog.log("session", "identity $identity")
|
||||||
|
|
||||||
// Startup sequence verified on hardware (csdk mag160c_ir.c): reader
|
|
||||||
// thread first, FFC(0) x2, 300 ms, then START(73). Without START the
|
|
||||||
// camera never streams (root cause of the round-11 black screen).
|
|
||||||
running.set(true)
|
running.set(true)
|
||||||
Thread.sleep(50)
|
Thread.sleep(50)
|
||||||
sendCmd(cmd8(MagProtocol.CMD_FFC, 0), epOut, epResp, "FFC(0) pre-start 1/2", 400)
|
|
||||||
sendCmd(cmd8(MagProtocol.CMD_FFC, 0), epOut, epResp, "FFC(0) pre-start 2/2", 400)
|
|
||||||
Thread.sleep(300)
|
|
||||||
sendCmd(cmd4(MagProtocol.CMD_START), epOut, epResp, "START", 400)
|
sendCmd(cmd4(MagProtocol.CMD_START), epOut, epResp, "START", 400)
|
||||||
streamLoop(pipe, epStream, epOut, epResp)
|
streamLoop(pipe, epStream, epOut, epResp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private fun prepareDdtBytes(ep84: UsbEndpoint?, bundled: ByteArray): ByteArray {
|
||||||
|
if (caliSize <= 0 || caliSize > 0x6400000L) {
|
||||||
|
DebugLog.log("session", "cali size invalid -> using 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)")
|
||||||
|
return try {
|
||||||
|
cache.readBytes()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DebugLog.log("session", "cache read failed -> bundled: $e")
|
||||||
|
bundled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ep84 == null) {
|
||||||
|
DebugLog.log("session", "EP 0x84 missing -> using bundled DDT")
|
||||||
|
return bundled
|
||||||
|
}
|
||||||
|
DebugLog.log(
|
||||||
|
"session",
|
||||||
|
"first run on this host: fetching cali file $caliSize B from EP 0x84",
|
||||||
|
)
|
||||||
|
val out = ByteArray(caliSize.toInt())
|
||||||
|
val buf = ByteArray(0x80000)
|
||||||
|
var got = 0
|
||||||
|
while (got < out.size) {
|
||||||
|
val want = minOf(buf.size, out.size - got)
|
||||||
|
val n = conn.bulkTransfer(ep84, buf, want, 60000)
|
||||||
|
if (n <= 0) {
|
||||||
|
diagnoseEndpoint(conn, ep84.address, got)
|
||||||
|
DebugLog.log("session", "cali read failed at $got/${out.size} -> bundled DDT")
|
||||||
|
return bundled
|
||||||
|
}
|
||||||
|
System.arraycopy(buf, 0, out, got, n)
|
||||||
|
got += n
|
||||||
|
if (got % 0x100000L < 0x80000L) {
|
||||||
|
DebugLog.log("session", "cali fetch progress $got/${out.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
cache.parentFile?.mkdirs()
|
||||||
|
cache.writeBytes(out)
|
||||||
|
DebugLog.log("session", "cali fetched+cached: ${cache.name} ($got B)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DebugLog.log("session", "cali cache write failed (using bytes anyway): $e")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
private fun notify(state: State, message: String?) {
|
private fun notify(state: State, message: String?) {
|
||||||
DebugLog.log("session", "state -> $state msg=$message")
|
DebugLog.log("session", "state -> $state msg=$message")
|
||||||
listener?.onStateChanged(state, message)
|
listener?.onStateChanged(state, message)
|
||||||
@@ -254,6 +332,14 @@ class IrSession(context: Context) {
|
|||||||
if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) {
|
if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) {
|
||||||
lastInfo1 = buf.copyOfRange(4, 4 + 0x38)
|
lastInfo1 = buf.copyOfRange(4, 4 + 0x38)
|
||||||
}
|
}
|
||||||
|
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) {
|
if (magic == MagProtocol.RSP_INFO_0 && n >= 0x3C) {
|
||||||
val payload = buf.copyOfRange(4, n)
|
val payload = buf.copyOfRange(4, n)
|
||||||
lastInfo0 = payload.copyOf(0x38)
|
lastInfo0 = payload.copyOf(0x38)
|
||||||
@@ -325,6 +411,11 @@ class IrSession(context: Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
readCount++
|
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) {
|
if (firstReads < 3) {
|
||||||
DebugLog.log(
|
DebugLog.log(
|
||||||
"stream", "first reads [$firstReads] n=$n head=%s".format(
|
"stream", "first reads [$firstReads] n=$n head=%s".format(
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ package com.mag160c.thermal.usb
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vendor command/response protocol, recovered in analysis/protocol_spec.md.
|
* Vendor command/response protocol, recovered in analysis/protocol_spec.md
|
||||||
* Plain commands are 4-byte {magic}; FFC carries an 8-byte {magic, param}.
|
* + the official Android libcoresdk decompilation (CNetComm).
|
||||||
* Responses on EP 0x82: 0x5BB5B55B camera info (0x38), 0x5BB5B55C block 2,
|
* Plain commands are 4-byte {magic}; FFC and parameter setters carry
|
||||||
* 0x5BB5B55E version pair (0x10).
|
* 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...").
|
||||||
*/
|
*/
|
||||||
object MagProtocol {
|
object MagProtocol {
|
||||||
const val CMD_PREPARE1 = 0x6BB6B66B
|
const val CMD_PREPARE1 = 0x6BB6B66B
|
||||||
const val CMD_PREPARE2 = 0x6BB6B66C
|
const val CMD_PREPARE2 = 0x6BB6B66C
|
||||||
const val CMD_GET_INFO = 0x6BB6B66F
|
const val CMD_GET_INFO = 0x6BB6B66F
|
||||||
const val CMD_GET_VERSION = 0x6BB6B670
|
const val CMD_GET_CALI = 0x6BB6B670
|
||||||
const val CMD_FFC = 0x6BB6B672
|
const val CMD_FFC = 0x6BB6B672
|
||||||
const val CMD_START = 0x6BB6B673
|
const val CMD_START = 0x6BB6B673
|
||||||
const val CMD_STOP = 0x6BB6B674
|
const val CMD_STOP = 0x6BB6B674
|
||||||
|
|||||||
@@ -151,6 +151,15 @@ class UsbTransport(private val context: Context) {
|
|||||||
return Triple(out, resp, stream)
|
return Triple(out, resp, stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Any endpoint by address (e.g. EP 0x84 for the calibration file). */
|
||||||
|
fun endpointByAddress(addr: Int): UsbEndpoint? {
|
||||||
|
val intf = claimedInterface ?: return null
|
||||||
|
for (i in 0 until intf.endpointCount) {
|
||||||
|
if (intf.getEndpoint(i).address == addr) return intf.getEndpoint(i)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
fun isOpen(): Boolean = connection != null
|
fun isOpen(): Boolean = connection != null
|
||||||
|
|
||||||
/** Raw connection handle for bulk transfers. */
|
/** Raw connection handle for bulk transfers. */
|
||||||
|
|||||||
Binary file not shown.
@@ -290,6 +290,32 @@
|
|||||||
- 预期:本轮装上后,日志应出现 `first reads [0] n=...` 与递增的 frames/rendered,
|
- 预期:本轮装上后,日志应出现 `first reads [0] n=...` 与递增的 frames/rendered,
|
||||||
画面出图。若 timeouts 持续上涨且 reads=0,再看 5s re-kick 与 halted 状态。
|
画面出图。若 timeouts 持续上涨且 reads=0,再看 5s re-kick 与 halted 状态。
|
||||||
|
|
||||||
|
## 用户反馈修复 第十五轮(2026-09-10,BREAKTHROUGH:逆向官方 libcoresdk 找到缺失握手)
|
||||||
|
|
||||||
|
- [x] **用户回传第三轮日志**:clear_halt 修复已生效(0x81 每 500ms 健康轮询 25s
|
||||||
|
无遗漏),但相机确实 0 字节;66b 仅在相机上电后响应一次,随后对一切命令
|
||||||
|
沉默,~27s 后相机掉线重启。**官方 App 在同一手机正常** → 排除供电/硬件。
|
||||||
|
- [x] **逆向 analysis/ida/export 的 libcoresdk(arm64) 反编译(官方在用的库)**:
|
||||||
|
- `CNetComm` 命令层全貌:66f=ReadCaliInfo(响应 0x5BB5B55E pair={u64 标定
|
||||||
|
文件大小, u64 版本});**670=GetCaliFile**(响应带大小 → 从 **EP 0x84**
|
||||||
|
按 ≤512KB 块、60s 超时读回标定文件;日志串 "First running on new host,
|
||||||
|
it will cost some time for initializing...");672=FFC;673=START;
|
||||||
|
674=STOP;676/677=省电参数(AutoPowerSave,非图像传输)。
|
||||||
|
- **官方安卓连接序列根本不发 66b/66c**(66b 是 PC libmagcore 旧演示遗留),
|
||||||
|
也不发预启动 FFC;序列 = 66f →(缓存缺失)670+0x84 拉取 → 读线程 →
|
||||||
|
50ms → START。FFC 由帧驱动(pipeline onFfc)。
|
||||||
|
- 推断:我们一直发的 66b 把该固件带入旧式握手模式并卡死(66b 有响应、
|
||||||
|
之后全哑、看门狗 ~25s 重启)——与全部日志吻合。
|
||||||
|
- [x] **IrSession 按官方序列重写连接**:66f(1000ms 读,解析 pair)→ 缓存
|
||||||
|
`files/cali/magcore.cali.<version>` 命中则直接用 → 否则 670 + EP 0x84
|
||||||
|
拉取(60s/块,进度日志,成功后写缓存)→ loadDdt(拉取文件优先,失败回退
|
||||||
|
内置 DDT)→ 50ms → START。删除 66b/66c/预启动 FFC×2;首帧后补发一次
|
||||||
|
66b 拿 info 块(MDT 用)。UsbTransport 加 endpointByAddress()。
|
||||||
|
- [x] 构建+单测全过;APK 已更新(11.87MB)。
|
||||||
|
- 预期日志特征:`66f cali-info ok` + `cali pair: size=1856416...` →
|
||||||
|
`first run on this host: fetching cali file...` → `cali fetched+cached` →
|
||||||
|
`first reads [0] n=...` → frames 递增 → 出图。
|
||||||
|
|
||||||
## 待办
|
## 待办
|
||||||
|
|
||||||
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——进行中:
|
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——进行中:
|
||||||
|
|||||||
Reference in New Issue
Block a user