From cfb586cb5ffc5a69e66cd9adbc57e4ebf725d1d3 Mon Sep 17 00:00:00 2001 From: zoukai Date: Mon, 14 Sep 2026 15:31:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E5=BC=8F=E5=BA=94=E7=94=A8=20PC=20=E7=AB=AF=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SKILL.md | 10 +- agents/openai.yaml | 4 +- references/application-debugging.md | 97 +++ references/build-workflow.md | 9 +- scripts/module_debug.py | 1095 +++++++++++++++++++++++++++ 5 files changed, 1206 insertions(+), 9 deletions(-) create mode 100644 references/application-debugging.md create mode 100644 scripts/module_debug.py diff --git a/SKILL.md b/SKILL.md index d830bc7..87bab69 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: embedded-app-pc-debug -description: 用于 Windows 电脑端的嵌入式应用调试,包括固件远程打包、设备发现与身份核对、ROM 升级、串口实时监测、升级后验证和故障诊断。适用于 Eapil/CamTells 工作流,也允许覆盖主机、项目、设备、串口和输出路径;普通代码开发或仅查看已有日志时不要自动触发。 +description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编译、NFS 模块部署、开机进程与加密参数调试,以及设备发现、ROM 升级、串口监测和故障诊断。适用于 Eapil/CamTells 工作流;普通代码开发或仅查看已有日志时不要自动触发。 --- # Windows电脑端调试 @@ -10,6 +10,7 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括固件远 ## 按需读取 - 涉及远程编译、工程定位、软链接、下载产物时,读取 [references/build-workflow.md](references/build-workflow.md)。 +- 涉及 NFS 模块部署、`DxMain`、开机启动脚本、工厂模式或加密 param 时,读取 [references/application-debugging.md](references/application-debugging.md)。 - 涉及广播搜索、基础信息、EPFP 文件传输或升级时,读取 [references/device-protocols.md](references/device-protocols.md)。 - 涉及串口监测、升级成功判据或故障定位时,读取 [references/validation-and-diagnostics.md](references/validation-and-diagnostics.md)。 - 涉及 Linux 打包任务与 Windows 设备任务之间交接时,读取 [references/cross-host-coordination.md](references/cross-host-coordination.md)。 @@ -19,7 +20,7 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括固件远 ## 默认环境 - SSH 配置主机:`emb.eapil.com` -- 远程项目根目录:`/CamTells` +- 远程工程搜索根:优先现有工作区、`~/work/develop/gitcode` 和实际挂载点;`/CamTells` 仅是兼容候选 - 默认 Public 来源:`~/work/develop/gitcode/EMX2.1` - 本地归档根目录:`F:\tmp\embedded-app-pc-debug` - 串口默认值:`115200`、`8N1`、无流控 @@ -29,7 +30,7 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括固件远 ## 工作方式 1. 根据用户请求确定需要执行的阶段;不强迫每次都完成整条链路。 -2. 自己调查文件、脚本参数、网卡和设备响应等可查事实,只向用户询问真正需要选择的事项。串口调试必须先取得本次用户确认的 COM 口;Telnet 调试必须先取得完整 SN 和型号。 +2. 自己调查文件、脚本参数、网卡和设备响应等可查事实,只向用户询问真正需要选择的事项。新设备调试开始前先枚举串口并让用户选择串口或 Telnet;即使只有一个 USB 串口也不自动打开。串口必须取得本次用户确认的 COM 口;Telnet 必须先取得完整 SN 和型号。 3. 将每次完整任务归档到 `<根目录>\<项目>\`,分别保存 `firmware` 和 `logs`。 4. 搜索可以按型号、IP 或唯一响应列出候选,但这些条件都不能替代完整 SN。Telnet、升级及其他设备写操作必须以用户给出的完整 SN 精确匹配目标,并核对型号;多个候选或同一 SN 出现冲突时停止,不能默认第一台。 5. 先启动串口监测,再执行设备升级;串口输出同时实时显示并保存日志。需要在网页中查看日志或向设备输入命令时,使用单进程串口网页控制台,避免采集进程和发送进程争用同一个 COM 口。网页首次打开显示最新日志,所有发送内容都写入文本日志,空输入也必须发送真实 `CR`。刷新网页不能中断采集;多个页面共用服务时仅关闭最后一个页面才触发停止。最后页面关闭后,即使处于升级期间,也要在约 `5` 秒内结束采集、保留日志并释放 COM 口;之后必须重新启动控制台才能再次连接。 @@ -37,6 +38,8 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括固件远 7. 只有用户在看到上述信息后明确确认升级,才能传输 ROM、烧录或触发重启。之前对搜索、编译或诊断的授权不能替代这次确认。 8. 升级失败后先诊断并保留证据,不自动重试;再次升级需要新的明确确认。 +串口优先不等于自动选择。用户选择串口后,只有端口打不开、无数据、乱码或无法登录时才建议回退 Telnet;切换前告知用户,Telnet 连接前重新广播核对完整 SN、型号和当前 IP。网络异常但串口可用时可以在用户确认 COM 口后做只读检查;设备写入、停止进程、修改启动项或重启前还需确认该串口对应的目标 SN 和型号。固件升级不得绕过协议身份核验。 + 用户如果只要求“显示串口”或“实时看日志”,仅打开只读串口监测;用户希望在页面持续查看时可以使用本地串口网页控制台,但不自动发送命令、搜索设备、登录、打包、传输文件或升级。 ## Git 与构建边界 @@ -52,6 +55,7 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括固件远 - `scripts/serial_monitor.ps1`:无第三方依赖的 Windows 串口实时输出、原始字节保存和带时间戳文本日志。 - `scripts/serial_web_console.ps1`:使用 Windows PowerShell 5.1 启动本地串口网页控制台;由单个进程独占 COM 口并同时负责接收、记录和发送,页面仅监听 `127.0.0.1`,写操作和关闭通知使用随机会话令牌。网页只提供用户主动输入和发送,不启动 U-Boot 持续按键或自动切换 A/B 分区。最后网页关闭后服务退出,不部署常驻管理进程或自动重连。必须用 `powershell.exe` 启动,不要改用 `pwsh.exe`。 - `scripts/v2_discover.py`:枚举适合广播的 Windows IPv4 网卡,从每个接口的本地 `8951` 向设备 `8941` 发送 V2 广播,并严格筛选完整 SN/型号。 +- `scripts/module_debug.py`:应用模块部署预检和受保护执行;支持已打开的串口网页控制台或 Telnet,默认只读预检。`DxMain` 在线替换会检查 `daemon.sh`,关键系统服务默认拒绝替换。 运行脚本前先查看 `--help` 或 PowerShell 参数说明。不得绕过脚本的完整 SN、ROM 路径、日志目录和执行开关保护。 diff --git a/agents/openai.yaml b/agents/openai.yaml index d54de00..e0f45fa 100644 --- a/agents/openai.yaml +++ b/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Windows电脑端调试" - short_description: "完成 Windows 电脑端设备发现、固件升级、串口监测与验证" - default_prompt: "使用 $embedded-app-pc-debug 在 Windows 电脑端锁定目标设备,并完成固件升级、串口监测和结果验证。" + short_description: "完成嵌入式模块部署、启动调试、参数诊断与固件升级" + default_prompt: "使用 $embedded-app-pc-debug 在 Windows 电脑端选择目标设备,完成应用或固件调试并验证结果。" policy: allow_implicit_invocation: true diff --git a/references/application-debugging.md b/references/application-debugging.md new file mode 100644 index 0000000..74a5671 --- /dev/null +++ b/references/application-debugging.md @@ -0,0 +1,97 @@ +# 应用模块、启动流程与参数调试 + +仅在任务涉及应用模块部署、NFS、`DxMain`、开机进程、工厂模式或 param 文件时读取本文件。所有主机、用户名、设备、路径和进程名都以当前任务实查为准,示例不能变成跨用户或跨型号默认值。 + +## 连接与身份 + +1. 新设备任务开始前枚举 Windows 串口,让用户选择串口或 Telnet;即使只有一个 USB 串口也不自动打开。串口默认 `115200 8N1`、无流控,但允许用户覆盖。 +2. 用户选择串口时复用 `serial_web_console.ps1` 的单进程收发服务。部署工具只能连接状态为 `isOpen=true` 且 COM 口与本次选择一致的网页控制台,不能再次直接打开串口。 +3. 串口打不开、无数据、乱码或无法登录时先报告事实,再建议 Telnet。不要静默切换;Telnet 前重新广播核对完整 SN、型号和当前 IP。 +4. 网络故障时,用户确认 COM 口后允许串口只读诊断。写文件、停止进程、修改启动脚本或重启前,还需用户确认这个 COM 口对应的完整 SN 和型号。固件升级始终需要协议身份核验。 +5. Telnet 登录名和密码从准确的当前工程 `product.json` 读取,并在可用时核对固件生成的 `/etc/passwd` 或 `/etc/shadow`。不要混用 Web、ONVIF、RTSP、Wi-Fi 或烧录平台密码;认证失败后停止,不连续猜测。 + +## NFS 与存储 + +NFS 服务器 IP 和用户名按用户或任务变化。当前任务尚未确认时询问;已经确认且仍可达时可以继续复用。常见形式仅用于帮助识别: + +```sh +mount -t nfs <服务器IP>:/nfs/<用户名> /mnt/nfs -o nolock +``` + +挂载前确认服务器可达、导出路径正确、挂载点存在,并检查是否已有不同来源挂载。复制前记录源文件的绝对路径、大小、权限、`file` 结果和 SHA-256。 + +只有 `/proc/mounts` 或 `findmnt` 明确显示为独立可写挂载时才把目录称为 SD 卡。存在 `/mnt/sdcard` 空目录不等于存在 SD 卡;真实 SD 卡可用时优先作为调试目录,否则选择具有足够空间的 eMMC 等可写分区。默认保留 NFS 挂载、调试文件、备份和日志,只有用户明确要求恢复或清理时才删除或卸载,并列出所有遗留文件。 + +## 普通模块部署 + +1. 确认源二进制与设备的 ELF 架构、ABI、动态加载器和动态库要求匹配。服务器本机 x86 编译成功不能作为设备可运行证据。 +2. 在目标文件同目录写入唯一临时文件,复制后校验 SHA-256 和权限,再用同文件系统内的原子重命名替换。覆盖现有文件前保存带时间戳的备份。 +3. 只停止任务明确指定的进程。部署后检查程序实际启动、观察期内没有立即退出或崩溃,并验证用户指定的功能或日志标记。普通一次性 demo 可以退出码 0 和预期输出为成功;常驻进程还需持续存活。 +4. `EmxCoreServer`、`EmxMediaServer`、`EmxModulesServer` 等可能触发设备强制重启的关键服务默认拒绝在线替换;必须由任务明确指定并单独确认。 +5. 使用 `scripts/module_debug.py` 时默认只预检。实际挂载、复制、停止或启动进程必须显式传入执行开关;脚本的开关不能替代用户授权。 +6. Telnet 模式会先调用同目录的 V2 搜索工具,从所有可用活动 IPv4 网卡重新广播(包含虚拟/VPN 网卡,排除回环和链路本地地址),完整 SN、型号和广播返回 IP 唯一匹配后才允许登录;已知 IP 的 30101 查询是第二道核验,不能替代广播。 +7. NFS 已挂载时必须与本次确认的服务器和导出路径完全一致。源程序必须通过 ELF 架构和解释器检查;设备缺少 `readelf` 时默认停止,只有人工看过有限检查结果后才可显式放行。 +8. 在线停止已有进程时必须提供从当前设备实查得到的失败恢复命令。脚本会在替换失败时尝试恢复原文件和原进程,并在设备 `/tmp/codex-module-<模块>-<动作>-<时间>.state` 记录事务阶段;出现命令超时、会话中断、掉电或 `ROLLBACK_FAILED` 时状态未知,禁止直接重试,必须重新核对设备身份后读取事务状态,检查进程、目标哈希和备份,不能宣称已自动恢复。 + +## DxMain 与 daemon.sh + +不要在 `daemon.sh` 或其后台软件看门狗仍运行时停止 `DxMain`。守护逻辑可能重新启动 `DxMain`,关键服务丢失时还可能执行强制重启。 + +在线替换顺序: + +1. 从当前设备脚本和进程表精确定位 `daemon.sh` 主进程及其后台 `SoftwareWatchdog` 子进程。 +2. 先发送 `TERM`,等待并检查;仍残留时记录 PID 后发送 `KILL`。父子守护进程全部消失前不能停止 `DxMain`。 +3. 对 `DxMain` 使用相同的 `TERM`、等待、必要时 `KILL` 顺序。发现设备重启、会话断开或守护/应用重新出现时立即停止部署并保留日志。 +4. 备份、原子部署并运行调试版本。调试结束后恢复原程序,重新启动正确参数的守护脚本,并确认 `DxMain` 与守护进程均恢复。 + +使用部署脚本在线停止 `DxMain` 时,必须同时传入本次实查的 `daemon.sh` 绝对路径和完整恢复命令。脚本按绝对路径最多接受一个 daemon 匹配,并且最多接受一个 `SoftwareWatchdog`;看门狗必须以该 daemon 为父进程,父子关系不匹配或出现多个候选时都拒绝结束任何进程,先人工核对进程树。 + +开机调试前读取当前设备的 `appinit.sh` 和 `allstart.sh`。部分工程会优先运行 `/root/configs/allstart.sh`、其次 `/mnt/emmc/allstart.sh`、最后固件默认脚本,也可能原生支持 `/mnt/emmc/DxMain`;这些都是按工程确认的实现,不是通用常量。两个启动覆盖文件同时存在时停止并报告遮蔽关系,不自行选择。 + +## 开机进程与重启验证 + +默认固件分区可能是只读 SquashFS。需要改变开机进程时,优先使用当前 `appinit.sh` 已支持的可写覆盖路径,并遵守: + +1. 记录默认脚本、现有覆盖文件、挂载属性、权限和 SHA-256。 +2. 修改现有覆盖文件前保存时间戳备份;从默认脚本创建覆盖时保留权限。 +3. 只修改目标启动项,新脚本先执行 `sh -n`,写入后校验 SHA-256。 +4. 如果两个候选覆盖文件会互相遮蔽,停止并让用户选择保留哪一个。 +5. 重启前串口必须已经打开并确实收到数据,同时给出可以通过串口执行的恢复命令。完成修改不等于取得重启授权;集中展示变更、备份和预期后等待明确的“确认重启”。 +6. 重启后通过串口观察完整启动过程,核对目标进程、守护进程、工厂程序、异常退出、panic、看门狗和循环重启;网络恢复后再使用协议或 Telnet 辅助验证。 + +## 烧录状态与工厂模式 + +未烧录设备可能自动进入产测流程;已烧录设备通常进入正常流程,只有强制工厂标志生效时才运行产测程序。具体判断可能使用 MAC/SN 文件、文件大小、加密烧录数据、环境变量或标志文件,必须从当前工程和设备的 `function.sh`、`appinit.sh`、`allstart.sh` 实查。 + +不要把某个版本的文件名、大小阈值、返回码或产测程序写成所有设备的规则。检查到未定义变量、错误文件路径或判断反转时报告证据并给出补丁,不自动修改设备。只有用户明确要求进入或退出工厂模式时,才允许创建、修改或删除工厂标志、烧录信息或启动覆盖文件。 + +## 加密 param 文件 + +设备 param 所在分区可能可读写,但内容需要 `DevTools` 解密。先识别格式,不能根据 `.json` 后缀直接调用解密: + +- 文件以 `{` 或 `[` 开头且能解析为 JSON:已经是明文,直接读取。 +- 内容符合当前工程加密参数格式:使用当前固件匹配的 `DevTools`。 +- 格式未知:查找对应模块的专用工具和源码,不试错解密。 + +部分实测版本的命令为: + +```text +DevTools param dec <文件路径> +DevTools param enc <文件路径> +``` + +命令必须从当前设备的帮助、二进制字符串或工程源码重新核实。不要只看退出码;某些版本对不支持的命令仍返回 0,输入格式错误时还可能段错误并把目标截断为空文件。 + +只读检查默认流程: + +1. 记录原始加密文件的路径、权限、大小和 SHA-256。 +2. 复制到唯一的 `/tmp` 文件并用 `cmp` 确认副本一致。 +3. 只对副本执行解密。成功后验证文件非空且为合法 JSON,再读取内容。 +4. 原文件前后重新计算 SHA-256,必须一致。解密失败、段错误或产生空文件时立即停止,不用同一源文件连续重试。 +5. 明文副本默认保留到当前调试任务结束,随后删除并记录;用户明确要求保留时不删除。 + +如果工具只能原地工作,解密读取后必须立即重新加密,并通过备份和哈希确认恢复;能使用 `/tmp` 副本时不要原地解密。 + +修改 param 时全程操作临时副本:解密、只改指定字段、验证 JSON 和字段差异、重新加密,再复制一份加密结果解密复查。写回前展示设备身份、原路径、备份路径、字段差异和校验值,等待明确“确认写入”。确认后备份原始加密文件,在同目录写临时文件并原子替换,恢复权限、`sync`,再解密复查。失败时保留原文件,不继续服务重载或重启。 + +同目录的 active、`.def` 和 `.bak` 文件作用不同。默认只操作任务明确的 active 文件;默认值与备份只用于比对,除非用户明确要求同步修改。参数写回、服务重载和整机重启是三个独立授权阶段。优先调查进程是否支持重新加载;必须重启时仍需单独确认。 diff --git a/references/build-workflow.md b/references/build-workflow.md index 1092024..ff38cd1 100644 --- a/references/build-workflow.md +++ b/references/build-workflow.md @@ -7,7 +7,7 @@ | 项目 | 默认值 | |---|---| | SSH Host | `emb.eapil.com` | -| 工程根目录 | `/CamTells` | +| 工程搜索根 | 当前远程工作区、`~/work/develop/gitcode`、实际挂载点;`/CamTells` 仅作兼容候选 | | Public 来源 | `~/work/develop/gitcode/EMX2.1` | | 本地归档根目录 | `F:\tmp\embedded-app-pc-debug` | @@ -16,9 +16,10 @@ ## 工程定位 1. 先用现有 SSH/VS Code 配置解析 Host,不猜测用户名、端口或私钥路径。 -2. 将用户给出的项目名视为 `/CamTells` 下的相对路径并精确匹配。 -3. 如果有多个候选,列出远程绝对路径等待选择;不要凭目录修改时间挑选。 -4. 确认工程目录包含待用脚本,再确定真实命令行参数。 +2. 优先使用用户给出的绝对路径。只给项目名时,从 VS Code 最近工作区、`~/work/develop/gitcode`、`/CamTells` 和实际挂载点按目录名精确搜索;搜索深度至少覆盖从用户目录起 12 层,并为每个根设置独立合理超时。 +3. PowerShell 调用远端 Shell 时,避免让本地提前展开远端 `$HOME` 等变量。记录每个搜索根的退出码并明确区分:没有匹配、无权访问、路径未挂载、命令不存在和搜索超时。超时不等于项目不存在。 +4. 如果有多个候选,列出远程绝对路径等待选择;不要凭目录修改时间挑选。 +5. 确认工程目录包含待用脚本,再确定真实命令行参数。类似 `product/CamTells/EPM21C` 的深层产品目录不能因浅层 `find -maxdepth` 被排除。 ## 构建顺序 diff --git a/scripts/module_debug.py b/scripts/module_debug.py new file mode 100644 index 0000000..6396a6c --- /dev/null +++ b/scripts/module_debug.py @@ -0,0 +1,1095 @@ +#!/usr/bin/env python3 +"""嵌入式应用模块部署预检与受保护执行工具。 + +默认只执行身份核验和设备端只读预检。只有同时指定非 preflight 动作与 +--execute 时,才会挂载 NFS、停止进程、复制文件或恢复文件。 +""" + +from __future__ import annotations + +import argparse +import getpass +import html +import json +import os +import re +import shlex +import shutil +import socket +import struct +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + + +INFO_COMMAND = 30101 +CONFIG_PORT = 8942 +CRITICAL_MODULES = { + "EmxCoreServer", + "EmxMediaServer", + "EmxModulesServer", + "WatchDog", +} +CRITICAL_MODULE_KEYS = {name.casefold() for name in CRITICAL_MODULES} + + +class DebugError(RuntimeError): + """可直接展示给用户的受控错误。""" + + +def configure_output() -> None: + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="backslashreplace") + + +class EventLogger: + def __init__(self, log_dir: Path) -> None: + log_dir.mkdir(parents=True, exist_ok=True) + self.path = log_dir / "module-debug.jsonl" + + def write(self, event: str, **fields: Any) -> None: + record = { + "time": time.strftime("%Y-%m-%d %H:%M:%S"), + "event": event, + **fields, + } + line = json.dumps(record, ensure_ascii=False) + with self.path.open("a", encoding="utf-8") as stream: + stream.write(line + "\n") + print(line) + + +def recv_exact(sock: socket.socket, length: int) -> bytes: + data = bytearray() + while len(data) < length: + chunk = sock.recv(length - len(data)) + if not chunk: + raise DebugError("设备在响应完成前关闭了连接") + data.extend(chunk) + return bytes(data) + + +def query_identity(host: str, timeout: float) -> Dict[str, Any]: + """通过 EPTP/30101 查询设备身份并校验响应。""" + session = int(time.time_ns() % 2_000_000_000) + request = json.dumps( + {"cmd": INFO_COMMAND, "session": session}, separators=(",", ":") + ).encode("utf-8") + with socket.create_connection((host, CONFIG_PORT), timeout=timeout) as sock: + sock.settimeout(timeout) + sock.sendall(b"EPTP" + struct.pack("!I", len(request)) + request) + header = recv_exact(sock, 8) + if header[:4] != b"EPTP": + raise DebugError("30101 响应同步头不是 EPTP") + size = struct.unpack("!I", header[4:])[0] + if size < 2 or size > 1024 * 1024: + raise DebugError("30101 响应长度异常:%d" % size) + response = json.loads(recv_exact(sock, size).decode("utf-8")) + if response.get("cmd") != INFO_COMMAND: + raise DebugError("30101 响应命令不匹配") + if response.get("session") != session: + raise DebugError("30101 响应 session 不匹配") + if response.get("code") != 0: + raise DebugError("30101 返回失败:%s" % response) + return response + + +def identity_models(response: Dict[str, Any]) -> List[str]: + data = response.get("data") or {} + result: List[str] = [] + for key in ("inner", "outer", "hardware"): + value = (data.get(key) or {}).get("type") + if value: + result.append(str(value)) + return result + + +def verify_identity( + response: Dict[str, Any], expected_sn: str, expected_model: str +) -> None: + data = response.get("data") or {} + actual_sn = str(data.get("sn") or "") + models = identity_models(response) + if actual_sn != expected_sn: + raise DebugError("设备 SN 不匹配:期望 %s,实际 %s" % (expected_sn, actual_sn)) + if expected_model not in models: + raise DebugError( + "设备型号不匹配:期望 %s,实际 %s" + % (expected_model, ", ".join(models) or "<空>") + ) + + +def broadcast_verify_identity( + expected_host: str, expected_sn: str, expected_model: str, timeout: float +) -> Dict[str, Any]: + """调用同目录 V2 搜索脚本,从全部活动网卡重新广播并核对目标 IP。""" + discover_script = Path(__file__).with_name("v2_discover.py") + if not discover_script.is_file(): + raise DebugError("找不到 V2 广播搜索脚本:%s" % discover_script) + command = [ + sys.executable, + str(discover_script), + "--expected-sn", + expected_sn, + "--expected-model", + expected_model, + "--include-virtual", + "--timeout", + str(max(0.5, min(timeout, 30.0))), + ] + result = subprocess.run( + command, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=max(10.0, min(timeout, 30.0) + 10.0), + check=False, + ) + records: List[Dict[str, Any]] = [] + for line in result.stdout.splitlines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(record, dict): + records.append(record) + summaries = [item for item in records if item.get("event") == "summary"] + if result.returncode != 0 or not summaries: + detail = result.stderr.strip() or result.stdout.strip() or "无搜索输出" + raise DebugError("V2 广播身份核验失败:%s" % detail) + summary = summaries[-1] + matches = summary.get("matches") or [] + if not summary.get("identity_verified") or len(matches) != 1: + raise DebugError("V2 广播没有唯一匹配目标设备") + match = matches[0] + actual_host = str(match.get("source_ip") or "") + if actual_host != expected_host: + raise DebugError( + "广播发现的目标 IP 已变化:期望 %s,实际 %s" % (expected_host, actual_host) + ) + return summary + + +class DeviceSession: + def run(self, command: str, timeout: float = 20.0) -> Tuple[int, str]: + raise NotImplementedError + + def close(self) -> None: + pass + + +class SerialWebSession(DeviceSession): + """复用已有串口网页控制台,不直接打开 COM 口。""" + + def __init__(self, base_url: str, expected_port: str, timeout: float) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.client_id = "module-debug-" + uuid.uuid4().hex + page = self._request("/").decode("utf-8", errors="replace") + match = re.search(r'data-token="([^"]+)"', page) + if not match: + raise DebugError("串口网页控制台没有返回会话令牌") + self.token = html.unescape(match.group(1)) + status = json.loads( + self._request("/api/status?client=" + urllib.parse.quote(self.client_id)) + ) + if not status.get("isOpen"): + raise DebugError("串口网页控制台存在,但串口没有打开") + actual_port = str(status.get("port") or "") + if actual_port.upper() != expected_port.upper(): + raise DebugError( + "串口控制台端口不匹配:期望 %s,实际 %s" + % (expected_port, actual_port) + ) + + def _request( + self, path: str, data: Optional[bytes] = None, token: bool = False + ) -> bytes: + headers: Dict[str, str] = {} + if data is not None: + headers["Content-Type"] = "text/plain; charset=utf-8" + if token: + headers["X-Serial-Token"] = self.token + request = urllib.request.Request( + self.base_url + path, + data=data, + headers=headers, + method="POST" if data is not None else "GET", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + return response.read() + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise DebugError("串口网页控制台请求失败:%s" % exc) from exc + + def run(self, command: str, timeout: float = 20.0) -> Tuple[int, str]: + marker = "__CODEX_MODULE_RESULT_%s__" % uuid.uuid4().hex + wrapped = "(%s); rc=$?; printf '\\n%s=%%s\\n' \"$rc\"" % (command, marker) + start_log = self._request( + "/api/log?client=" + urllib.parse.quote(self.client_id) + ).decode("utf-8", errors="replace") + self._request( + "/api/send?ending=CR", + data=wrapped.encode("utf-8"), + token=True, + ) + deadline = time.monotonic() + timeout + latest = start_log + pattern = re.compile(re.escape(marker) + r"=(-?\d+)") + while time.monotonic() < deadline: + time.sleep(0.2) + latest = self._request( + "/api/log?client=" + urllib.parse.quote(self.client_id) + ).decode("utf-8", errors="replace") + matches = pattern.findall(latest) + if matches: + offset = min(len(start_log), len(latest)) + return int(matches[-1]), latest[offset:] + raise DebugError( + "等待串口命令结束标记超时;设备端命令可能仍在执行,状态未知,禁止直接重试;" + "请读取事务状态文件并核对进程和目标哈希。不会静默切换 Telnet" + ) + + +class TelnetSession(DeviceSession): + """只实现登录和 Shell 命令所需的最小 Telnet 协议。""" + + IAC = 255 + DONT = 254 + DO = 253 + WONT = 252 + WILL = 251 + SB = 250 + SE = 240 + + def __init__( + self, host: str, port: int, username: str, password: str, timeout: float + ) -> None: + self.sock = socket.create_connection((host, port), timeout=timeout) + self.sock.settimeout(0.5) + self.timeout = timeout + self.pending = b"" + self._read_until((b"login:",), timeout) + self._send_line(username) + self._read_until((b"Password:",), timeout) + self._send_line(password) + marker = "__CODEX_LOGIN_%s__" % uuid.uuid4().hex + self._send_line("printf '\\n%s\\n'" % marker) + try: + token = marker.encode("ascii") + self._read_until((b"\n" + token + b"\r\n", b"\n" + token + b"\n"), timeout) + except DebugError as exc: + raise DebugError("Telnet 登录失败或未进入 Shell;停止尝试凭据") from exc + + def _strip_telnet(self, data: bytes) -> bytes: + data = self.pending + data + self.pending = b"" + output = bytearray() + index = 0 + while index < len(data): + if data[index] != self.IAC: + output.append(data[index]) + index += 1 + continue + if index + 1 >= len(data): + self.pending = data[index:] + break + command = data[index + 1] + if command == self.IAC: + output.append(self.IAC) + index += 2 + elif command in (self.DO, self.DONT, self.WILL, self.WONT): + if index + 2 >= len(data): + self.pending = data[index:] + break + option = data[index + 2] + reply = self.WONT if command in (self.DO, self.DONT) else self.DONT + self.sock.sendall(bytes((self.IAC, reply, option))) + index += 3 + elif command == self.SB: + end = data.find(bytes((self.IAC, self.SE)), index + 2) + if end < 0: + self.pending = data[index:] + break + index = end + 2 + else: + index += 2 + return bytes(output) + + def _read_until(self, tokens: Sequence[bytes], timeout: float) -> bytes: + deadline = time.monotonic() + timeout + buffer = bytearray() + while time.monotonic() < deadline: + try: + chunk = self.sock.recv(4096) + except socket.timeout: + continue + if not chunk: + raise DebugError("Telnet 连接提前关闭") + buffer.extend(self._strip_telnet(chunk)) + if any(token in buffer for token in tokens): + return bytes(buffer) + raise DebugError("等待 Telnet 响应超时") + + def _send_line(self, text: str) -> None: + self.sock.sendall(text.encode("utf-8") + b"\r\n") + + def _read_until_regex(self, pattern: re.Pattern[bytes], timeout: float) -> bytes: + deadline = time.monotonic() + timeout + buffer = bytearray() + while time.monotonic() < deadline: + try: + chunk = self.sock.recv(4096) + except socket.timeout: + continue + if not chunk: + raise DebugError("Telnet 连接提前关闭") + buffer.extend(self._strip_telnet(chunk)) + if pattern.search(buffer): + return bytes(buffer) + raise DebugError( + "等待 Telnet 命令结束标记超时;设备端命令可能仍在执行,状态未知," + "禁止直接重试;请重新核对身份后读取事务状态文件、进程和目标哈希" + ) + + def run(self, command: str, timeout: float = 20.0) -> Tuple[int, str]: + marker = "__CODEX_MODULE_RESULT_%s__" % uuid.uuid4().hex + wrapped = "(%s); rc=$?; printf '\\n%s=%%s\\n' \"$rc\"" % (command, marker) + self._send_line(wrapped) + byte_pattern = re.compile(re.escape(marker).encode("ascii") + rb"=-?\d+") + data = self._read_until_regex(byte_pattern, timeout) + text = data.decode("utf-8", errors="replace") + matches = re.findall(re.escape(marker) + r"=(-?\d+)", text) + if not matches: + raise DebugError("Telnet 命令没有返回有效结束标记") + return int(matches[-1]), text + + def close(self) -> None: + try: + self._send_line("exit") + except OSError: + pass + self.sock.close() + + +def remote_file(ssh_host: str, remote_path: str, timeout: float) -> str: + if not remote_path.startswith("/") or "\n" in remote_path or "\r" in remote_path: + raise DebugError("远程工程路径必须是安全的绝对路径") + command = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=%d" % max(1, int(timeout)), + ssh_host, + "cat " + shlex.quote(remote_path), + ] + result = subprocess.run( + command, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout + 5, + check=False, + ) + if result.returncode != 0: + raise DebugError("读取远程文件失败:%s" % result.stderr.strip()) + return result.stdout + + +def load_telnet_credentials( + ssh_host: str, project_path: str, username: str, timeout: float +) -> Tuple[str, str]: + product_path = project_path.rstrip("/") + "/product.json" + product = json.loads(remote_file(ssh_host, product_path, timeout)) + users = product.get("user") or [] + for item in users: + if str(item.get("name") or "") == username: + password = str(item.get("password") or "") + if not password: + raise DebugError("product.json 中目标用户的密码为空") + return username, password + raise DebugError("product.json 中未找到 Telnet 用户 %s" % username) + + +def q(value: str) -> str: + return shlex.quote(value) + + +def validate_device_path(value: str, label: str) -> None: + if not value.startswith("/"): + raise DebugError("%s必须是设备绝对路径" % label) + if "\n" in value or "\r" in value or "\x00" in value: + raise DebugError("%s包含非法字符" % label) + + +def is_critical_target(module_name: str, target_path: str) -> bool: + names = {module_name.casefold(), Path(target_path).name.casefold()} + return bool(names & CRITICAL_MODULE_KEYS) + + +def preflight_command(args: argparse.Namespace) -> str: + source = q(args.source_path) + target = q(args.target_path) + mount_path = q(args.nfs_mount) + module = q(args.module_name) + return " ".join( + [ + "printf 'PREFLIGHT_BEGIN\\n';", + "test -f %s || { echo SOURCE_MISSING; exit 41; };" % source, + "echo SOURCE_PATH=%s;" % source, + "ls -l %s;" % source, + "command -v file >/dev/null 2>&1 || { echo FILE_TOOL_MISSING; exit 44; };", + "file_output=$(file -L %s 2>/dev/null) || exit 44;" % source, + "echo FILE_RESULT=$file_output;", + "case \"$file_output\" in *ELF*) ;; *) echo SOURCE_NOT_ELF; exit 45;; esac;", + "device_arch=$(uname -m); echo DEVICE_ARCH=$device_arch;", + "case \"$device_arch:$file_output\" in arm*:*ARM*|aarch64:*ARM*|mips*:*MIPS*|x86_64:*x86-64*|i?86:*Intel*80386*) ;; *) echo ELF_ARCH_MISMATCH; exit 46;; esac;", + "if command -v readelf >/dev/null 2>&1; then", + "readelf -h %s 2>/dev/null || exit 47;" % source, + r"interp=$(readelf -l %s 2>/dev/null | sed -n 's/.*Requesting program interpreter: \(.*\)]/\1/p');" + % source, + "if [ -n \"$interp\" ]; then echo ELF_INTERPRETER=$interp; test -e \"$interp\" || { echo ELF_INTERPRETER_MISSING; exit 48; }; fi;", + "else %s fi;" + % ( + "echo READELF_UNAVAILABLE_LIMITED_ABI_CHECK;" + if args.allow_limited_abi_check + else "echo READELF_REQUIRED; exit 49;" + ), + "sha256sum %s;" % source, + "echo TARGET_PATH=%s;" % target, + "target_dir=$(dirname %s);" % target, + "test -d \"$target_dir\" || { echo TARGET_DIR_MISSING; exit 42; };", + "test -w \"$target_dir\" || { echo TARGET_DIR_NOT_WRITABLE; exit 43; };", + "grep -F %s /proc/mounts 2>/dev/null || echo NFS_MOUNT_NOT_PRESENT;" + % q(" %s " % args.nfs_mount), + "echo MODULE=%s;" % module, + "pidof %s 2>/dev/null || true;" % module, + "ps -o pid,ppid,args 2>/dev/null | grep '[d]aemon[.]sh' || true;", + "printf 'PREFLIGHT_END\\n'", + ] + ) + + +def nfs_mount_command(args: argparse.Namespace) -> Optional[str]: + if not args.mount_nfs: + return None + if not args.nfs_server or not args.nfs_export: + raise DebugError("--mount-nfs 需要同时提供 --nfs-server 和 --nfs-export") + source = "%s:%s" % (args.nfs_server, args.nfs_export) + return " ".join( + [ + "expected_source=%s; mount_point=%s;" % (q(source), q(args.nfs_mount)), + "existing_source=$(awk -v m=\"$mount_point\" '$2 == m {print $1; exit}' /proc/mounts);", + "if [ -n \"$existing_source\" ] && [ \"$existing_source\" != \"$expected_source\" ]; then", + "echo NFS_SOURCE_MISMATCH:$existing_source:$expected_source; exit 31; fi;", + "if [ -z \"$existing_source\" ]; then mount -t nfs \"$expected_source\" \"$mount_point\" -o nolock; fi;", + "mounted_source=$(awk -v m=\"$mount_point\" '$2 == m {print $1; exit}' /proc/mounts);", + "test \"$mounted_source\" = \"$expected_source\" || { echo NFS_VERIFY_FAILED; exit 32; }", + ] + ) + + +def stop_process_fragment( + module_name: str, dxmain: bool, daemon_script_path: Optional[str] = None +) -> str: + fragments: List[str] = [] + if dxmain: + fragments.extend( + [ + "daemon_path=%s; shell_pid=$$; ps_snapshot=$(ps -o pid,ppid,args);" + % q(daemon_script_path or ""), + "daemon_pids=$(printf '%s\n' \"$ps_snapshot\" | awk -v self=\"$shell_pid\" -v marker=\"$daemon_path\" '$1 != self && index($0, marker) > 0 {print $1}');", + "daemon_count=$(printf '%s\n' \"$daemon_pids\" | awk 'NF {n++} END {print n+0}');", + "test \"$daemon_count\" -le 1 || { echo MULTIPLE_DAEMON_MATCHES:$daemon_pids; exit 69; };", + "all_watchdog_pids=$(pidof SoftwareWatchdog 2>/dev/null || true);", + "watchdog_count=$(printf '%s\n' \"$all_watchdog_pids\" | awk '{print NF}');", + "test \"$watchdog_count\" -le 1 || { echo MULTIPLE_WATCHDOG_MATCHES:$all_watchdog_pids; exit 68; };", + "watchdog_pids=$(printf '%s\n' \"$ps_snapshot\" | awk -v parent=\"$daemon_pids\" '$2 == parent && /[S]oftwareWatchdog/ {print $1}');", + "test -z \"$all_watchdog_pids\" || test \"$watchdog_pids\" = \"$all_watchdog_pids\" || { echo WATCHDOG_PARENT_MISMATCH:$all_watchdog_pids:$daemon_pids; exit 65; };", + "app_pids=$(pidof %s 2>/dev/null || true);" % q(module_name), + "test \"$daemon_count\" = 1 || { echo EXPECTED_DAEMON_NOT_RUNNING; exit 67; };", + "test -n \"$app_pids\" || { echo EXPECTED_PROCESS_NOT_RUNNING; exit 66; };", + "watchdog_was_running=0; test -z \"$watchdog_pids\" || watchdog_was_running=1;", + "app_was_running=1; process_recovery_needed=1;", + "guard_pids=\"$daemon_pids $watchdog_pids\";", + "if [ -n \"$(printf %s \"$guard_pids\" | tr -d ' ')\" ]; then kill -TERM $guard_pids 2>/dev/null || true; fi;", + "sleep 2;", + "ps_snapshot=$(ps -o pid,ppid,args);", + "daemon_pids=$(printf '%s\n' \"$ps_snapshot\" | awk -v self=\"$shell_pid\" -v marker=\"$daemon_path\" '$1 != self && index($0, marker) > 0 {print $1}');", + "watchdog_pids=$(pidof SoftwareWatchdog 2>/dev/null || true);", + "guard_pids=\"$daemon_pids $watchdog_pids\";", + "if [ -n \"$(printf %s \"$guard_pids\" | tr -d ' ')\" ]; then kill -KILL $guard_pids 2>/dev/null || true; fi;", + "sleep 1;", + "ps_snapshot=$(ps -o pid,ppid,args);", + "daemon_pids=$(printf '%s\n' \"$ps_snapshot\" | awk -v self=\"$shell_pid\" -v marker=\"$daemon_path\" '$1 != self && index($0, marker) > 0 {print $1}');", + "watchdog_pids=$(pidof SoftwareWatchdog 2>/dev/null || true);", + "test -z \"$daemon_pids$watchdog_pids\" || { echo GUARD_STILL_RUNNING:$daemon_pids:$watchdog_pids; exit 71; };", + ] + ) + fragments.extend( + [ + "app_pids=$(pidof %s 2>/dev/null || true);" % q(module_name), + "app_was_running=0; test -z \"$app_pids\" || app_was_running=1;", + "test \"$app_was_running\" = 0 || process_recovery_needed=1;", + "if [ -n \"$app_pids\" ]; then kill -TERM $app_pids 2>/dev/null || true; fi;", + "sleep 2;", + "app_pids=$(pidof %s 2>/dev/null || true);" % q(module_name), + "if [ -n \"$app_pids\" ]; then kill -KILL $app_pids 2>/dev/null || true; fi;", + "sleep 1;", + "app_pids=$(pidof %s 2>/dev/null || true);" % q(module_name), + "test -z \"$app_pids\" || { echo PROCESS_STILL_RUNNING:$app_pids; exit 72; };", + ] + ) + return " ".join(fragments) + + +def recovery_start_fragment(args: argparse.Namespace) -> str: + """生成失败回滚后的进程恢复与存活核验命令。""" + if not args.stop_existing: + return "true" + if args.module_name.casefold() == "dxmain": + return " ".join( + [ + "{ shell_pid=$$; ps_snapshot=$(ps -o pid,ppid,args);", + "daemon_now=$(printf '%%s\n' \"$ps_snapshot\" | awk -v self=\"$shell_pid\" -v marker=%s '$1 != self && index($0, marker) > 0 {print $1}');" + % q(args.daemon_script_path), + "if [ -z \"$daemon_now\" ]; then %s >/tmp/daemon-debug.log 2>&1 & fi;" + % args.daemon_start_command, + "sleep 2;", + "ps_snapshot=$(ps -o pid,ppid,args);", + "printf '%%s\n' \"$ps_snapshot\" | awk -v self=\"$$\" -v marker=%s '$1 != self && index($0, marker) > 0 {found=1} END {exit !found}' &&" + % q(args.daemon_script_path), + "pidof DxMain >/dev/null 2>&1 &&", + "{ test \"$watchdog_was_running\" = 0 || printf '%s\n' \"$ps_snapshot\" | awk -v parent=\"$daemon_now\" '$2 == parent && /[S]oftwareWatchdog/ {found=1} END {exit !found}'; }; }", + ] + ) + return " ".join( + [ + "{ if [ \"$app_was_running\" = 1 ] && ! pidof %s >/dev/null 2>&1; then %s >%s 2>&1 & fi;" + % ( + q(args.module_name), + args.rollback_start_command, + q("/tmp/%s-rollback.log" % args.module_name), + ), + "sleep 2;", + "{ test \"$app_was_running\" = 0 || pidof %s >/dev/null 2>&1; }; }" + % q(args.module_name), + ] + ) + + +def rollback_function(restore_command: str, args: argparse.Namespace) -> str: + recovery = recovery_start_fragment(args) + return " ".join( + [ + "rollback(){ rc=$?; trap - 0;", + "if [ \"$file_rollback_needed\" = 1 ] || [ \"$process_recovery_needed\" = 1 ]; then", + "printf 'ROLLBACK_STARTED\n' >\"$transaction\";", + "rollback_ok=1;", + "if [ \"$file_rollback_needed\" = 1 ]; then %s || rollback_ok=0; fi;" + % restore_command, + "sync;", + "if [ \"$process_recovery_needed\" = 1 ]; then %s || rollback_ok=0; fi;" + % recovery, + "if [ \"$rollback_ok\" != 1 ]; then printf 'ROLLBACK_FAILED\n' >\"$transaction\"; echo ROLLBACK_FAILED; exit 90; fi;", + "printf 'ROLLBACK_COMPLETED\n' >\"$transaction\";", + "echo ROLLBACK_COMPLETED;", + "fi; exit \"$rc\"; };", + "trap rollback 0;", + ] + ) + + +def deploy_command(args: argparse.Namespace) -> Tuple[str, str, str]: + stamp = time.strftime("%Y%m%d-%H%M%S") + unique = stamp + "-" + uuid.uuid4().hex[:8] + source = args.source_path + target = args.target_path + stage = target + ".codex-new-" + unique + backup = target + ".codex-backup-" + unique + transaction = "/tmp/codex-module-%s-deploy-%s.state" % (args.module_name, unique) + dxmain = args.module_name.casefold() == "dxmain" + parts = [ + "src=%s; dst=%s; stage=%s; backup=%s; transaction=%s;" + % (q(source), q(target), q(stage), q(backup), q(transaction)), + "had_dst=0; file_rollback_needed=0; process_recovery_needed=0; app_was_running=0; watchdog_was_running=0;", + rollback_function( + "if [ \"$had_dst\" = 1 ]; then cp -p \"$backup\" \"$dst\"; else rm -f \"$dst\"; fi", + args, + ), + "printf 'PREPARING\n' >\"$transaction\"; echo TRANSACTION_PATH=$transaction;", + "test -f \"$src\" || exit 41;", + "mode=755; if [ -e \"$dst\" ]; then mode=$(stat -c %a \"$dst\"); fi;", + "cp \"$src\" \"$stage\" || exit 51;", + "chmod \"$mode\" \"$stage\" || exit 52;", + "src_hash=$(sha256sum \"$src\" | awk '{print $1}');", + "stage_hash=$(sha256sum \"$stage\" | awk '{print $1}');", + "test \"$src_hash\" = \"$stage_hash\" || { echo HASH_MISMATCH; exit 53; };", + "if [ -e \"$dst\" ]; then had_dst=1; cp -p \"$dst\" \"$backup\" || exit 54; echo BACKUP_PATH=$backup; fi;", + ] + if args.stop_existing: + parts.append("printf 'STOPPING\n' >\"$transaction\";") + parts.append( + stop_process_fragment(args.module_name, dxmain, args.daemon_script_path) + ) + parts.append("printf 'STOPPED\n' >\"$transaction\";") + else: + parts.append( + "test -z \"$(pidof %s 2>/dev/null || true)\" || { echo PROCESS_RUNNING_REQUIRES_STOP; exit 70; };" + % q(args.module_name) + ) + parts.extend( + [ + "file_rollback_needed=1; printf 'REPLACING\n' >\"$transaction\";", + "mv -f \"$stage\" \"$dst\" || exit 55;", + "printf 'REPLACED\n' >\"$transaction\";", + "sync;", + "echo TARGET_SHA256=$(sha256sum \"$dst\" | awk '{print $1}');", + ] + ) + if args.start: + start_args = " ".join(q(value) for value in args.start_arg) + parts.append("\"$dst\" %s &" % start_args) + parts.append("sleep %d;" % args.observe_seconds) + parts.append( + "pidof %s >/dev/null 2>&1 || { echo PROCESS_NOT_RUNNING; exit 74; };" + % q(args.module_name) + ) + parts.append("echo PROCESS_PIDS=$(pidof %s);" % q(args.module_name)) + parts.extend( + [ + "file_rollback_needed=0; process_recovery_needed=0;", + "printf 'COMPLETED\n' >\"$transaction\";", + "trap - 0;", + ] + ) + return " ".join(parts), backup, transaction + + +def restore_command(args: argparse.Namespace) -> Tuple[str, str]: + stamp = time.strftime("%Y%m%d-%H%M%S") + unique = stamp + "-" + uuid.uuid4().hex[:8] + target = args.target_path + backup = args.backup_path + stage = target + ".codex-restore-" + unique + before_restore = target + ".codex-before-restore-" + unique + transaction = "/tmp/codex-module-%s-restore-%s.state" % (args.module_name, unique) + parts = [ + "backup=%s; dst=%s; stage=%s; before_restore=%s; transaction=%s;" + % (q(backup), q(target), q(stage), q(before_restore), q(transaction)), + "had_current=0; file_rollback_needed=0; process_recovery_needed=0; app_was_running=0; watchdog_was_running=0;", + rollback_function( + "if [ \"$had_current\" = 1 ]; then cp -p \"$before_restore\" \"$dst\"; else rm -f \"$dst\"; fi", + args, + ), + "printf 'PREPARING\n' >\"$transaction\"; echo TRANSACTION_PATH=$transaction;", + "test -f \"$backup\" || { echo BACKUP_MISSING; exit 61; };", + "cp -p \"$backup\" \"$stage\" || exit 62;", + "test \"$(sha256sum \"$backup\" | awk '{print $1}')\" = \"$(sha256sum \"$stage\" | awk '{print $1}')\" || exit 63;", + "if [ -e \"$dst\" ]; then had_current=1; cp -p \"$dst\" \"$before_restore\" || exit 67; fi;", + ] + if args.stop_existing: + parts.append("printf 'STOPPING\n' >\"$transaction\";") + parts.append( + stop_process_fragment( + args.module_name, + args.module_name.casefold() == "dxmain", + args.daemon_script_path, + ) + ) + parts.append("printf 'STOPPED\n' >\"$transaction\";") + else: + parts.append( + "test -z \"$(pidof %s 2>/dev/null || true)\" || { echo PROCESS_RUNNING_REQUIRES_STOP; exit 70; };" + % q(args.module_name) + ) + parts.extend( + [ + "file_rollback_needed=1; printf 'REPLACING\n' >\"$transaction\";", + "mv -f \"$stage\" \"$dst\" || exit 64;", + "printf 'REPLACED\n' >\"$transaction\";", + "sync;", + ] + ) + if args.start: + start_args = " ".join(q(value) for value in args.start_arg) + parts.extend( + [ + "\"$dst\" %s &" % start_args, + "sleep %d;" % args.observe_seconds, + "pidof %s >/dev/null 2>&1 || exit 65;" % q(args.module_name), + ] + ) + if args.restart_daemon: + parts.append("%s || exit 66;" % recovery_start_fragment(args)) + parts.extend( + [ + "file_rollback_needed=0; process_recovery_needed=0;", + "printf 'COMPLETED\n' >\"$transaction\";", + "if [ \"$had_current\" = 1 ]; then rm -f \"$before_restore\"; fi;", + "trap - 0;", + ] + ) + return " ".join(parts), transaction + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="应用模块部署预检与受保护执行;默认不修改设备。" + ) + parser.add_argument("--validate-only", action="store_true", help="只执行本地自检") + parser.add_argument("--host", help="目标设备 IP") + parser.add_argument("--expected-sn", help="目标设备完整 SN") + parser.add_argument("--expected-model", help="目标设备型号") + parser.add_argument("--module-name", help="模块进程/程序名称") + parser.add_argument("--source-path", help="设备可见的源文件绝对路径") + parser.add_argument("--target-path", help="设备目标文件绝对路径") + parser.add_argument( + "--action", choices=("preflight", "deploy", "restore"), default="preflight" + ) + parser.add_argument("--backup-path", help="restore 动作使用的设备备份文件") + parser.add_argument("--execute", action="store_true", help="允许执行设备写操作") + parser.add_argument( + "--transport", choices=("serial-web", "telnet"), default="serial-web" + ) + parser.add_argument("--serial-url", default="http://127.0.0.1:8766") + parser.add_argument("--serial-port", help="本次用户确认的 COM 口") + parser.add_argument("--confirm-serial-target", action="store_true") + parser.add_argument("--telnet-port", type=int, default=23) + parser.add_argument("--telnet-user", default="root") + parser.add_argument("--ssh-host", help="读取当前工程 product.json 的 SSH Host") + parser.add_argument("--project-path", help="当前设备对应的远程工程绝对路径") + parser.add_argument("--nfs-server", help="本次用户确认的 NFS 服务器") + parser.add_argument("--nfs-export", help="本次用户确认的 NFS 导出路径") + parser.add_argument("--nfs-mount", default="/mnt/nfs") + parser.add_argument("--mount-nfs", action="store_true") + parser.add_argument("--stop-existing", action="store_true") + parser.add_argument("--start", action="store_true") + parser.add_argument("--start-arg", action="append", default=[]) + parser.add_argument("--observe-seconds", type=int, default=3) + parser.add_argument("--restart-daemon", action="store_true") + parser.add_argument( + "--daemon-start-command", + help="恢复时按当前设备实查得到的 daemon 启动命令;不提供硬编码默认值", + ) + parser.add_argument( + "--daemon-script-path", + help="DxMain 在线调试前从当前设备实查得到的 daemon.sh 绝对路径", + ) + parser.add_argument( + "--rollback-start-command", + help="普通进程停止后若部署失败,用于恢复原进程的实查启动命令", + ) + parser.add_argument( + "--allow-limited-abi-check", + action="store_true", + help="设备缺少 readelf 时,明确允许只使用 file 和 uname 做有限 ABI 核验", + ) + parser.add_argument("--allow-critical", action="store_true") + parser.add_argument("--connect-timeout", type=float, default=5.0) + parser.add_argument("--command-timeout", type=float, default=30.0) + parser.add_argument("--log-dir", help="本次任务日志目录") + return parser + + +def local_validate() -> None: + assert q("/mnt/nfs/a b") == "'/mnt/nfs/a b'" + assert "daemon_pids" in stop_process_fragment("DxMain", True, "/x/daemon.sh") + assert "SoftwareWatchdog" in stop_process_fragment("DxMain", True, "/x/daemon.sh") + assert "daemon_pids" not in stop_process_fragment("Demo", False) + assert "EmxCoreServer" in CRITICAL_MODULES + common = [ + "--host", "192.0.2.1", + "--expected-sn", "TEST-SN", + "--expected-model", "EPM21C", + "--module-name", "DxMain", + "--target-path", "/mnt/emmc/DxMain", + "--log-dir", "/tmp/test-log", + "--serial-port", "COM1", + "--stop-existing", + "--daemon-script-path", "/root/firmware/scripts/daemon.sh", + "--daemon-start-command", "/root/firmware/scripts/daemon.sh 1", + ] + parser = build_parser() + deploy_args = parser.parse_args( + common + + ["--action", "deploy", "--source-path", "/mnt/nfs/DxMain", "--start"] + ) + deploy_shell, _, deploy_state = deploy_command(deploy_args) + assert "ROLLBACK_COMPLETED" in deploy_shell and deploy_state.endswith(".state") + restore_args = parser.parse_args( + common + + [ + "--action", "restore", + "--backup-path", "/mnt/emmc/DxMain.bak", + "--restart-daemon", + ] + ) + restore_shell, restore_state = restore_command(restore_args) + assert "SoftwareWatchdog" in restore_shell and restore_state.endswith(".state") + bash_candidates = [ + shutil.which("bash"), + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + ] + bash_path = next( + (candidate for candidate in bash_candidates if candidate and Path(candidate).is_file()), + None, + ) + if bash_path: + for label, shell_text in ( + ("deploy", deploy_shell), + ("restore", restore_shell), + ): + result = subprocess.run( + [bash_path, "-n"], + input=shell_text, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + raise AssertionError("%s Shell 语法错误:%s" % (label, result.stderr)) + print("module_debug.py 本地自检通过") + + +def require_arguments(args: argparse.Namespace) -> None: + required = ( + ("host", "--host"), + ("expected_sn", "--expected-sn"), + ("expected_model", "--expected-model"), + ("module_name", "--module-name"), + ("target_path", "--target-path"), + ("log_dir", "--log-dir"), + ) + missing = [option for field, option in required if not getattr(args, field)] + if args.action != "restore" and not args.source_path: + missing.append("--source-path") + if args.action == "restore" and not args.backup_path: + missing.append("--backup-path") + if args.transport == "serial-web" and not args.serial_port: + missing.append("--serial-port") + if args.transport == "telnet": + if not args.ssh_host: + missing.append("--ssh-host") + if not args.project_path: + missing.append("--project-path") + if args.restart_daemon and not args.daemon_start_command: + missing.append("--daemon-start-command") + if args.stop_existing and args.action != "preflight": + if args.module_name.casefold() == "dxmain": + if not args.daemon_start_command: + missing.append("--daemon-start-command") + if not args.daemon_script_path: + missing.append("--daemon-script-path") + elif not args.rollback_start_command: + missing.append("--rollback-start-command") + if missing: + raise DebugError("缺少参数:" + ", ".join(missing)) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + configure_output() + parser = build_parser() + args = parser.parse_args(argv) + if args.validate_only: + local_validate() + return 0 + require_arguments(args) + if args.observe_seconds < 0 or args.observe_seconds > 300: + raise DebugError("--observe-seconds 必须在 0 到 300 之间") + if not re.fullmatch(r"[A-Za-z0-9_.-]+", args.module_name): + raise DebugError("--module-name 只能包含字母、数字、点、下划线和连字符") + for value, label in ( + (args.daemon_start_command, "--daemon-start-command"), + (args.rollback_start_command, "--rollback-start-command"), + ): + if value and any(character in value for character in ("\r", "\n", "\x00")): + raise DebugError("%s 包含非法字符" % label) + if args.daemon_script_path: + validate_device_path(args.daemon_script_path, "--daemon-script-path") + if args.restart_daemon and args.action != "restore": + raise DebugError("--restart-daemon 只允许用于 restore 动作") + if args.restart_daemon and args.module_name.casefold() != "dxmain": + raise DebugError("--restart-daemon 只允许用于 DxMain 恢复") + if args.stop_existing and args.action != "preflight": + if args.module_name.casefold() == "dxmain": + if args.action == "deploy" and not args.start: + raise DebugError("DxMain 在线部署停止旧进程时必须同时指定 --start") + if args.action == "restore": + if not args.restart_daemon: + raise DebugError("DxMain 在线恢复必须指定 --restart-daemon") + if args.start: + raise DebugError("DxMain 在线恢复由 daemon 拉起,不能同时指定 --start") + elif not args.start: + raise DebugError("停止普通模块后必须同时指定 --start,避免成功返回时进程缺失") + validate_device_path(args.target_path, "--target-path") + validate_device_path(args.nfs_mount, "--nfs-mount") + if args.source_path: + validate_device_path(args.source_path, "--source-path") + if args.backup_path: + validate_device_path(args.backup_path, "--backup-path") + if is_critical_target(args.module_name, args.target_path) and args.action != "preflight": + if not args.allow_critical: + raise DebugError("关键服务默认拒绝替换;需要单独确认并传入 --allow-critical") + if args.action != "preflight" and not args.execute: + raise DebugError("deploy/restore 动作缺少 --execute,不会修改设备") + + logger = EventLogger(Path(args.log_dir)) + identity_verified = False + if args.transport == "telnet": + try: + discovery = broadcast_verify_identity( + args.host, + args.expected_sn, + args.expected_model, + args.connect_timeout, + ) + logger.write("broadcast_identity_verified", summary=discovery) + except Exception as exc: + logger.write("broadcast_identity_failed", error=str(exc)) + raise DebugError("Telnet 前必须完成 V2 广播身份核验") from exc + try: + response = query_identity(args.host, args.connect_timeout) + except Exception as exc: + logger.write("identity_failed", error=str(exc)) + if args.transport == "telnet": + raise DebugError("Telnet 前必须完成协议身份核验") from exc + if args.action != "preflight" and not args.confirm_serial_target: + raise DebugError( + "协议身份核验失败;串口写操作需要用户确认物理目标并传入 --confirm-serial-target" + ) from exc + else: + # 收到明确身份后,SN/型号冲突绝不允许被串口物理确认开关绕过。 + verify_identity(response, args.expected_sn, args.expected_model) + identity_verified = True + logger.write("identity_verified", response=response) + + session: Optional[DeviceSession] = None + try: + if args.transport == "serial-web": + session = SerialWebSession( + args.serial_url, args.serial_port, args.connect_timeout + ) + logger.write( + "transport_ready", + transport="serial-web", + serial_port=args.serial_port, + identity_verified=identity_verified, + ) + else: + username, password = load_telnet_credentials( + args.ssh_host, + args.project_path, + args.telnet_user, + args.connect_timeout, + ) + session = TelnetSession( + args.host, + args.telnet_port, + username, + password, + args.connect_timeout, + ) + logger.write("transport_ready", transport="telnet", user=username) + + mount_command = nfs_mount_command(args) + if mount_command: + if not args.execute: + logger.write("nfs_mount_planned", command=mount_command) + else: + rc, output = session.run(mount_command, args.command_timeout) + logger.write("nfs_mount", returncode=rc, output=output) + if rc != 0: + raise DebugError("NFS 挂载失败") + + if args.action in ("preflight", "deploy"): + rc, output = session.run(preflight_command(args), args.command_timeout) + logger.write("preflight", returncode=rc, output=output) + if rc != 0: + raise DebugError("设备端预检失败,退出码 %d" % rc) + else: + command = "if test -f %s; then ls -l %s; sha256sum %s; else echo BACKUP_MISSING; false; fi" % ( + q(args.backup_path), q(args.backup_path), q(args.backup_path) + ) + rc, output = session.run(command, args.command_timeout) + logger.write("restore_preflight", returncode=rc, output=output) + if rc != 0: + raise DebugError("恢复文件预检失败") + + if args.action == "preflight": + logger.write("complete", conclusion="只读预检完成,设备未修改") + return 0 + if args.action == "deploy": + command, backup, transaction = deploy_command(args) + logger.write( + "transaction_planned", + action="deploy", + transaction=transaction, + backup=backup, + ) + try: + rc, output = session.run( + command, args.command_timeout + args.observe_seconds + ) + except DebugError as exc: + logger.write( + "transaction_state_unknown", + transaction=transaction, + error=str(exc), + instruction="禁止直接重试;先读取设备事务状态并核对进程、文件哈希和备份", + ) + raise + logger.write( + "deploy", returncode=rc, output=output, planned_backup=backup + ) + if rc != 0: + raise DebugError("模块部署失败,退出码 %d" % rc) + else: + command, transaction = restore_command(args) + logger.write( + "transaction_planned", action="restore", transaction=transaction + ) + try: + rc, output = session.run( + command, args.command_timeout + args.observe_seconds + ) + except DebugError as exc: + logger.write( + "transaction_state_unknown", + transaction=transaction, + error=str(exc), + instruction="禁止直接重试;先读取设备事务状态并核对进程、文件哈希和备份", + ) + raise + logger.write("restore", returncode=rc, output=output) + if rc != 0: + raise DebugError("模块恢复失败,退出码 %d" % rc) + logger.write("complete", conclusion="执行完成;仍需按任务功能判据验证") + return 0 + finally: + if session is not None: + session.close() + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (DebugError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: + print("错误:%s" % exc, file=sys.stderr) + raise SystemExit(2)