Files
MAG160C/docs/android_app/execution_plan.md
T

22 KiB
Raw Blame History

MAG160C 执行计划 —— session_state 剩余待办(为无视觉执行模型编写)

执行状态(2026-09-10Phase A / B / C / D / E / F / Z 全部完成 每阶段一个 commitA=06c1f30 B=656d419 C=b7a928e D=c34940e E=f8b3200 F=512508e),44 个单测全绿,debug+release 双构建通过,APK 已更新。 唯一未完成项为真机实测(需用户配合,清单见 docs/android_app/real_device_checklist.md)与红热调色板精确表(材料不足)。 详细记录与诚实记录的限制见 docs/android_app/session_state.md

本文件写给没有视觉能力的执行模型。所有 UI 位置已由主模型用精确 dp/颜色/ 对齐方式定义,照抄即可,不要自行发明布局。所有协议字节已由官方反编译源码 确认(见 analysis/magcx_official_flow.md),不要修改协议常量。

主模型验收标准:每阶段结束时 gradlew test 全绿 + commit 存在 + 本文件 对应阶段被勾选。阶段顺序固定:A → B → C → D → E → F → Z。


0. 全局约束(每个阶段开始前重读一遍)

  1. 禁止修改
    • android/app/src/main/AndroidManifest.xmlscreenOrientation="portrait"
    • MagProtocol.kt 的命令字节序(MagProtocolTest 必须保持全绿)
    • IrSession.kt 的连接握手序列(66b→66c→66f→[670]→673800ms 超时)
    • LiveRenderer.kt 的构图逻辑(图像恒 90°CW 钉死竖屏框架)
    • analysis/ 目录内容(只读参考)
  2. 编码:所有源码 UTF-8。python 脚本读写文件必须显式 encoding='utf-8'
  3. 图标约定:双弧圆 M x,y a r,r 0 1,0 2r,0 a r,r 0 1,0 -2r,0z + 直线/矩形, 不许用手绘贝塞尔曲线。
  4. 构建命令(每完成一个逻辑单元就跑一次):
    cd /c/Project/MAG160C/android && export JAVA_HOME="C:\\Tools\\jdk-21" && \
    cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:assembleDebug test --no-daemon"
    
  5. 提交:每个阶段一个 commit,格式 android: <一句话>。禁止提交 build-artifacts/ 变更以外的无关文件;build-artifacts/mag160c-app-debug.apk 在阶段末尾用新构建覆盖提交。
  6. 阶段完成后:更新 docs/android_app/session_state.md 对应待办为已勾选, 并在该文件追加一行进度记录(日期 + 阶段字母 + 一句话)。
  7. 失败处理:同一编译错误尝试 3 次仍失败 → 停止该阶段,在 session_state 记录卡点,跳到下一个独立阶段(依赖关系见各阶段"前置")。禁止大段重写 已工作文件。
  8. 参考文档(执行某阶段前必读对应部分):
    • analysis/magcx_official_flow.md(协议权威)
    • docs/android_app/HANDOFF_DEVELOPMENT.mdApp 架构与约定)
    • docs/android_app/reverse_apk_features.md §4MDT 容器)

Phase A —— 真机数据通路补全 + 端到端自检清单(小,约 30 分钟)

前置:无(USB 流已打通)。

A1. GetLifeTime 命令支持

文件:MagProtocol.ktIrSession.kt

  1. MagProtocol 增加:
    const val CMD_GET_LIFETIME = 0x6BB6B675      // 已有,勿改值
    const val RSP_SEND_LIFETIME = 0x5BB5B561
    
    0x5BB6B675 = 1807136373 = 官方 P2D_GetLifeTime;响应 = 官方 D2P_SendLifeTime 0x5BB5B561payload = {u32 magic(=0x5BB5B561), i32 开机毫秒},共 8 字节。)
  2. IrSession 增加 @Volatile var deviceLifetimeMs: Long = -1private set)。
  3. startInternal 的第 3 步(GetCaliInfo)之后、obtainCali 之前插入:
    // 官方 getDevLifeTime675 → 0x5BB5B561 {magic, ms}
    writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_LIFETIME), "GetLifeTime")
    readResp(conn, epResp, "GetLifeTime")?.let { r ->
        if (r.second == MagProtocol.RSP_SEND_LIFETIME && r.first >= 12 &&
            MagProtocol.u32(r.third, 0) == MagProtocol.RSP_SEND_LIFETIME
        ) deviceLifetimeMs = MagProtocol.u32(r.third, 4).toLong()
    }
    DebugLog.log("session", "device lifetime=${deviceLifetimeMs}ms")
    
    失败不阻断(官方也是 log 后继续)。
  4. streamLoop 的每 2s 心跳 stats 行尾追加 lifetime=${deviceLifetimeMs}

A2. 标定文件一致性体检

obtainCali 的 cache-hit 分支里,读出 bytes 后与 bundled 比较 MD5 java.security.MessageDigest.getInstance("MD5")),日志输出 cali cache vs bundled DDT: identical/differ。不改变返回值(仍优先缓存)。

A3. 端到端自检清单文档

新建 docs/android_app/real_device_checklist.md,内容为用户可照做的步骤表: 插机授权 → 出流(预期日志行)→ 顶栏 FFC 点两次(预期 FFC ack 日志)→ 拍照(预期 saved + 相册 DCIM/MAG160C 出现 .jpg)→ 录像 10s 停止 (预期 rec_done + MP4 出现)→ 媒体库页看到缩略图 → 分析页打开 MDT → PDF 生成。每步标注"预期 DebugLog 行"(用现有日志文案)。

验证

  • gradlew test 全绿;安装 APK 到真机跑一遍 A3 清单(需要用户配合,无视觉 模型只保证编译与单测)。

提交信息android: lifetime query + cali consistency check + real-device checklist


Phase B —— MDT 温度解码(离线分析增强,中)

前置:无。必读media/Mdt.kt 全文、core/TempMath.kt 全文、 ui/analyze/AnalyzeViewModel.kt + ui/analyze/AnalyzeViewer.kt 全文。

B1. MDT 读取器

media/Mdt.kt 增加:

class MdtFile(
    val jpg: ByteArray,
    val info0: ByteArray?,   // 0x38B BasePara1
    val info1: ByteArray?,
    val framePixels: ByteArray?, // 38400B19200×u16LE
    val text: String?,
)
fun parse(bytes: ByteArray): MdtFile?

解析规则(与 compose 互逆,全部小端):

  1. 尾部 152B:校验 u32(尾部偏移 fileLen-152) == SECTION_TAIL(0x5BB5B57B) u32(+4) == ddtOffset;不合法返回 null。
  2. DDT 段自 ddtOffset136B 段头(u32(0)==SECTION_DDT(0x5BB5B55B) u32(4)==段体长度),随后类型块循环:{u32 magic, u32 len, data(对齐4)} magic ∈ {BLOCK_INFO0, BLOCK_INFO1, BLOCK_FRAME, BLOCK_TXT},读到段体尾。
  3. jpg = bytes[0 .. 首块对齐前)(即 bytes.copyOf(ddtOffset 对齐前实际 jpg 长度)—— 简化:jpg = bytes.copyOfRange(0, firstNonJpgPad),实现为:从 0 找 FF D9(JPEG EOI)最后一次出现位置 +2)。
  4. BLOCK_TXT 按 UTF-8 解码。

B2. 温度图计算

core/TempMath.kt 已有 countsToTempMc(counts: Int): Int(毫度)。新增:

fun tempMapFromPixels(pixels: ByteArray, w: Int = 160, h: Int): IntArray

对每个 u16LE 像素调 countsToTempMc,返回 19200 毫度数组。加单测:

  • 长度 19200;单调性抽样(对同像素递增 counts 输入断言递增输出);
  • 4000 counts ≈ 20~60℃ 区间(已有测试同样断言)。

B3. 分析页 UI精确规格,照抄

数据(AnalyzeViewModel):

  • var tempMap: IntArray? = null(加载 MDT 成功且 framePixels 非空时填充)
  • var probe: Pair<Int, Int>? = null(传感器坐标 x∈[0,159], y∈[0,119]
  • fun probeTempMc(x: Int, y: Int): Int? = tempMap?.get(y*160+x)

UIAnalyzeViewer.kt,画在图像绘制层之上):

  1. 温度条:贴图像显示区(与热像 fitRect 同宽)顶部内侧,高 28dp,圆角 4dp, 填充 Color(0x99000000),内边距水平 12dp;白色 12sp 文本一行: 中心 %.1f℃ 最低 %.1f℃ 最高 %.1f℃(中心=像素(80,60),最低/最高扫全图, 毫度 ÷1000 保留 1 位)。tempMap 为 null(纯 JPEG 文件)时不画。
  2. 点击测温detectTapGestures(若已有缩放手势则合并进现有 gesture, 不要新增第二个 pointerInput 覆盖)——把点击点按"显示 rect → 传感器坐标" 的固定 90° 逆映射换算(照抄 LiveViewModel.tapImage 内的 sy=(1-fx)*120; sx=fy*160 三行),设置 probe;再次点击同一点(±6dp)清除。 probe 标记:实心白圆半径 4dp + 外环描边 2dp 半径 9dp;标签矩形在触点右上 8dp,白底(0xF0FFFFFF)黑字 12sp 圆角 4dp,内容 %.1f℃;若右上超出色标条 左缘则翻转到左上。
  3. 温度条与标签随图像 fitRect 走(缩放平移时按 fitRect 重新定位,不随 手势矩阵)。

验证

  • 新增 MdtTestcompose 一个含 info0+framePixels+text 的 MDT → parse → 断言 jpg/frame/text/info0 字节一致;坏尾(改 1 字节)返回 null。
  • 真机验证留给用户(清单文档补一行"分析页顶部温度条出现")。

提交信息android: MDT temperature decode + analyzer probe UI


Phase C —— 厂商 12 调色板精确提取(脚本,中)

前置:无。材料C:\Tools\dl\magcx_libs\lib\armeabi\libcxsdk.so 372KB,官方 native 库)。

C1. 先把 .so 入库

mkdir -p analysis/sdk_re/android_app/bin
cp /c/Tools/dl/magcx_libs/lib/armeabi/libcxsdk.so analysis/sdk_re/android_app/bin/

C2. 提取脚本 analysis/tools/extract_palettes.py

逻辑(纯标准库,python3 显式 UTF-8):

  1. 读入 .so 全部字节。
  2. 滑窗扫描:对每个 4 字节对齐偏移 o,检查连续 256 个 u32LE: 统计满足 (v >> 24) == 0xFF(v & 0xFFFFFF) != 0 的比例 ≥ 240/256 → 记录候选 (o, 256)。允许运行起点为 o、o+4(调色板数组可能带表头)。
  3. OfficialTables.PALETTE256_ARGB(铁虹,从 android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTables.kt 导出 256 个 int)比对:若某候选与已知铁虹完全一致 → 打印 "ironbow anchor found at 0x%X"(这是提取器正确性的锚点)。相邻候选区段合并去重。
  4. 输出 analysis/sdk_re/android_app/palette_candidates.json [{offset, hexPreview:[前16个], length}]
  5. 生成对比图 analysis/sdk_re/android_app/palette_candidates.png: 每个候选画一行 256 色条(纯 zlib+struct 手写 PNG,无第三方依赖—— 代码骨架:PNG = 签名 + IHDR + IDAT(zlib deflate of raw scanlines) + IEND 每行 256×8px 缩放为 2048×32)。

C3. 判读与接线(需要用户看一眼 PNG)

  1. 主模型/用户从 PNG 认出 12 个官方调色板对应的候选行(官方顺序: 白热/黑热/铁虹/彩虹/琥珀/金秋/寒冬/热金属/喷射/红饱和/高对比/红热)。
  2. 执行模型把确认的候选按序写进 core/Palettes.kt
    • 保留 NAMES 顺序不变;
    • 每个 buildAll() 条目来源标注注释:// extracted @0x… libcxsdk// approximated (not found in binary)
    • 铁虹必须仍是 OfficialTables.PALETTE256_ARGB(有锚点校验)。
  3. 单测 PalettesTest:12 个条目均长 256、非全同色、铁虹与 OfficialTables 相等。

回退

若扫不到锚点(铁虹不在 .so 里):如实记录"未在 libcxsdk 静态数据中找到 调色板表(可能运行时生成)",保留现有一套近似实现并在 Palettes.kt 头注释 说明;本阶段只交付脚本与 JSON。

提交信息android: vendor palette extraction tooling (+exact tables if found)


Phase D —— 云模块脚手架(Retrofit opt-in,小)

前置:无。原则:默认关闭、零网络行为。

  1. gradle/libs.versions.toml 增加 retrofit = "2.11.0"app/build.gradle.kts 增加 implementation(libs.retrofit)implementation("com.squareup.retrofit2:converter-gson:2.11.0")
  2. 新建 cloud/CloudApi.kt(接口+数据类,参照官方专业版云端 cloudapi.magnity.com.cn,仅占位):
    interface CloudApi {
        @POST("v1/account/login")  fun login(@Body body: LoginReq): Call<LoginResp>
        @GET("v1/account/devices") fun devices(): Call<DeviceListResp>
        @POST("v1/account/taskfiles/{taskId}") fun upload(@Path("taskId") id: String, @Body body: RequestBody): Call<UploadResp>
        @GET("v1/account/taskfiles/{taskId}") fun taskfiles(@Path("taskId") id: String): Call<TaskFilesResp>
    }
    object CloudClient {
        val enabled: Boolean get() = AppSettings-enabled-flag (SharedPreferences "cloudEnabled", 默认 false)
        fun api(): CloudApi = Retrofit.Builder().baseUrl("https://cloudapi.magnity.com.cn/")
            .addConverterFactory(GsonConverterFactory.create()).build().create(CloudApi::class.java)
    }
    
    enabled 为 false 时任何调用方不得构造 Retrofit —— 加运行时断言。)
  3. ui/settings/AppSettings.kt 增加 var cloudEnabled: Boolean(默认 false)。
  4. 设置页 UI照抄):在"语言"行之后、"关于"行之前插入 SettingRow("云同步", if (enabled) "已开启" else "已关闭") { dialog = "cloud" } dialog 内容:说明文字"上传/任务同步需要账号,当前版本仅预留接口,不会发起 任何网络请求。"+ 按钮"开启/保持关闭"。
  5. release 混淆:app/proguard-rules.pro 追加 -keep class com.mag160c.thermal.cloud.** { *; }

验证:构建全绿;设置页出现该行(无视觉模型确保编译与文案)。

提交信息android: cloud module scaffold (retrofit, opt-in, disabled by default)


Phase E —— 可见光 PIP 融合(中)

前置:Phase B 已合入(同文件顶部逻辑)。必读ui/live/LiveScreen.kt 全文。

E1. 权限

AndroidManifest.xml 增加:

<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />

运行时权限:LiveScreen 内 rememberLauncherForActivityResult(RequestPermission()),首次开启 PIP 时请求。

E2. 状态(LiveViewModel.LiveState 增加)

val pipOn: Boolean = false,
val pipSizeIndex: Int = 1,            // 0/1/2 → 96/128/160dp 宽
val pipXf: Float = 1f,                // PIP 左上角 X,相对视口宽 0..1
val pipYf: Float = 0f,                // 相对"热像可视区顶"高 0..1

fun togglePip() / fun cyclePipSize() / fun setPipPos(xf, yf)

E3. 相机组件 ui/live/PipCameraView.kt

AndroidView 包装 TextureView + Camera2 最小实现(完整骨架,执行模型不得 改动结构只允许填注释处):

class PipCameraEngine(val context, val textureView) :
    TextureView.SurfaceTextureListener {
    open(): 选第一个 BACK 相机 → CameraManager.openCamera
    onSurfaceTextureAvailable → open → createCaptureSession(listOf(surface))
      → setRepeatingRequest(TEMPLATE_PREVIEW) 
    release(): session.close → camera.close → camera=null(幂等)
    错误全部 DebugLog("pip", ...) 并 release
}

(~150 行。任何相机错误只记日志 + 隐藏 PIP,绝不崩溃:外层包 try/catch。)

E4. LiveScreen 布局(精确坐标

  1. 顶栏第 5 项:现有 4 个 Box(weight 1f) 之后加第 5 个同构 Box Icon ic_pip + Text("画中画"),点击 vm.togglePip(),开启时 tint = colorScheme.primary。新建 res/drawable/ic_pip.xml 双弧圆(24dp 画布 M12,4 a8,8 0 1,0 0.01,0 a8,8 0 1,0 -0.01,0z 描边)
    • 右下小实心矩形 M13,13 h6 v6 h-6 z
  2. PIP 浮层(画在 Box 内、快门区之前):
    • 尺寸三档:宽 {96, 128, 160}dp,高 = 宽 × 3/4(相机 4:3 横向); 默认 index=1128×96dp)。
    • 位置:left = viewport.left + pipXf*(viewport.width - pipW) top = viewport.top + pipYf*(viewport.height - pipH)viewport 取 LiveRenderer 同款热像可视区(用 vm.uiTopPx/uiBottomPx 推算,Compose 侧 用 onSizeChanged 拿到的同一数值);初始 pipXf=1f、pipYf=0f(右上角)。 实际摆放再减去色标条占位:right 边距额外 +32dp(色标条 20dp+2×12dp), 即初始右上角留出色标条位置。
    • 外观:border(2.dp, Color.White, RoundedCornerShape(8.dp)),内部 TextureView clipToOutline=true
    • 手势:pointerInputdetectDragGestures 拖动 → 更新 pipXf/pipYf clamp 0..1);detectTapGestures(onTap = 循环尺寸, onDoubleTap = 关闭)
  3. 释放:pipOn=false、离开实时页、ON_STOP 时调用 engine.release()。

验证(无视觉)

  • 编译 + 现有单测全绿;DebugLog:pip: camera opened / released
  • 真机视觉验证项写入 real_device_checklist.mdPhase A 文档追加两行)。

提交信息android: visible-light PIP overlay (camera2, draggable, 3 sizes)


Phase F —— 网络互连远程预览(大,放最后)

前置A/E 已合入。设计定稿如下,不得更改协议参数。

F1. 协议(net/RemoteContract.kt,纯 Kotlin,可单测)

  • 发现UDP 47510。主机每 1000ms 向 255.255.255.255:47510 发一行 UTF-8 JSON {"app":"mag160c-remote","role":"host","name":"<Build.MODEL>","tcp":47511,"serial":160043865}
  • 控制TCP 47511,换行分隔 JSONUTF-8,单行 ≤4KB):
    • 客户端→主机:{"cmd":"hello","name":"..."} → 回 {"type":"welcome","w":160,"h":120,"fps":15,"serial":...}
    • {"cmd":"start"}{"type":"stream-start"} 后开始二进制帧
    • {"cmd":"stop"}{"type":"stream-stop"}
    • {"cmd":"ffc"} → 主机触发 FFC → {"type":"ok"}
  • 图像帧stream-start 之后,TCP 二进制流): [u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400][38400B 原始 u16LE 像素] 共 38412B/帧。客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染 (调色板/变倍全在客户端本地,无需回传)。
  • 保活:主机 3s 无帧发 {"type":"ping"};客户端 10s 无任何数据判死重连。

F2. 文件与职责

  1. net/RemoteContract.kt:常量(端口/魔数)、JSON data class、 encodeFramePacket(raw: ByteArray, counter: Int): ByteArrayFramePacketReader.feed(bytes): List<ByteArray>(流式重组)。 —— 单测 RemoteContractTestencode→feed 往返一致;截断/粘包两种输入。
  2. net/RemoteHost.kt(协程):
    • start(rawFrames: Flow<ByteArray>)DatagramSocket 广播协程 + ServerSocket(47511) accept 循环(单客户端,后来者拒绝写 busy); hello→welcomestart→订阅 rawFrames 逐帧写 38412B 包; stop→stream-stop。全部 IO 在 Dispatchers.IO,异常即断开清理。
    • stop():关 socket。
  3. IrSession.kt:增加
    @Volatile var rawHook: ((ByteArray) -> Unit)? = null
    
    在 streamLoop 中 lastRawFrame = frameBuf.copyOf() 之后调用 rawHook?.invoke(lastRawFrame!!)(与 recorderHook 并列)。
  4. net/RemoteClient.kt
    • startDiscovery(): Flow<HostInfo>47510 收包 → 解析 JSON → 去重 3s);
    • connect(ip): RemoteSessionTCPframes: Flow<ByteArray> 供渲染, send(cmd);断线 → flow 完成)。
  5. ui/settings:设置页"语言"与"关于"之间插两行: SettingRow("远程预览服务端", if(on) "已开启" else "已关闭")(点击切换, 开启时要求当前 USB 会话活跃——不活跃弹 AlertDialog 提示"先连接热像仪"); SettingRow("远程预览客户端", "查找主机")(导航到 F4 列表页)。
  6. ui/remote/RemoteClientListScreen.kt照抄规格):
    • 顶部标题栏高 56dp:左返回箭头 icon(双弧圆+向左横线)、标题"远程预览"16sp
    • 主机卡片列表:每项高 64dp,水平 16dp 边距;左侧 24dp 双弧圆图标, 主文本=主机名 16sp,副文本=IP:47511 12sp 灰;行尾 TextButton"连接"
    • 列表尾 8dp 间隔 + 全宽 48dp 高 OutlinedButton"重新扫描"
    • 分隔线 + 小标题"手动添加"14sp 灰,左 16dp+ Row(TextField placeholder"例如 192.168.1.23", weight 1f + TextButton"连接")
    • 空态居中 Text"正在扫描局域网主机…"(扫描中)/"未发现主机"10s 后)。
  7. ui/remote/RemoteViewerScreen.kt复制 LiveScreen 的骨架SurfaceView+ LiveRenderer 同款构图、顶栏 4 项、底导航不变),差异点:
    • 顶栏 FFC→发 {"cmd":"ffc"};变倍/追踪/调色板→客户端本地(本地管线);
    • 快门区仅保留中间一个红色圆形"断开"按钮(60dp,边 4dp #FFFF5252), 点击断开并回列表;相册/拍照不显示;
    • RemoteViewerViewModel:持有 RemoteSession + 本地 RenderPipeline+ loadDdt(bundled)frames flow → pipe.frame(frame, true, out) → latestFrame;温度刷新照抄 refreshTemps 的本地分支(probe 80,60)。
    • 断线:回列表 + Snackbar"连接已断开"。
  8. LiveViewModel:暴露 val rawFrameFlow = MutableSharedFlow<ByteArray>( extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)sessionListener.onFrameReadysession.lastRawFrame?.let { rawFrameFlow.tryEmit(it) };设置页服务端开关 on → remoteHost.start(rawFrameFlow)off → stop。
  9. Manifest<uses-permission android:name="android.permission.INTERNET" />

F3. 验证

  • 单测:RemoteContractTest(含 3 帧连发+粘包拆包)。
  • 真机:两台手机同局域网(或手机+模拟器),清单文档追加"远程预览"步骤与 预期日志(host: client connected from …client: frames=…)。

提交信息android: LAN remote preview (UDP discovery + raw-frame TCP stream, client-side rendering)


Phase Z —— 收尾

  1. docs/android_app/session_state.md:待办全部勾选/更新,追加本轮总结段。
  2. docs/android_app/HANDOFF_DEVELOPMENT.md:§3 结构表加 net/cloud/ui/remote/;§7 待办刷新。
  3. 全量 assembleDebug + test;提交 APKgit push origin main
  4. 汇报:各阶段 commit 列表 + 用户手测清单位置。

执行模型操作规程(摘要)

  • 每阶段:读本文件对应节 → 列 todo → 实现 → gradlew test 绿 → 按格式 commit → 更新 session_state → 下一阶段。
  • 不确定就查 analysis/magcx_official_flow.mdanalysis/jadx_magcx/ 源码, 不要发明协议字节
  • 禁止 push(由用户/主模型执行)。