Update embedded app debugging workflow

This commit is contained in:
zoukai committed 2026-10-22 14:09:49 +08:00
1 parent d50be29246
commit 38807e5f97
13 files changed
+846 -122

No files matched your search

+26 -10
View File
@@ -1,6 +1,6 @@
---
name: embedded-app-pc-debug
description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编译、NFS 模块部署、开机进程与加密参数调试,以及设备发现、ROM 升级、串口监测和故障诊断。适用于 Eapil/CamTells 工作流;普通代码开发或仅查看已有日志时不要自动触发。
description: 用于 Windows 电脑端的 Eapil 嵌入式产品与平台调试,包括远程编译、NFS 模块部署、设备发现、ROM 升级、串口监测和故障诊断;普通代码开发或仅查看已有日志时不要自动触发。
---
# Windows电脑端调试
@@ -11,16 +11,18 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
- 涉及远程编译、工程定位、软链接、下载产物时,读取 [references/build-workflow.md](references/build-workflow.md)。
- 涉及 NFS 模块部署、`DxMain`、开机启动脚本、工厂模式或加密 param 时,读取 [references/application-debugging.md](references/application-debugging.md)。
- 涉及 V1/V2 广播搜索、工厂/产测命令、统一配置、EPFP 文件传输、音视频协议或升级时,读取 [references/device-protocols.md](references/device-protocols.md)。
- 涉及广播搜索、基础信息、EPFP 文件传输或升级时,读取 [references/device-protocols.md](references/device-protocols.md)。
- 涉及串口监测、升级成功判据或故障定位时,读取 [references/validation-and-diagnostics.md](references/validation-and-diagnostics.md)。
- 涉及 USB Wi-Fi、驱动加载、`wlan0`、关联、DHCP、PC 多网卡或网络恢复时,也读取 [references/validation-and-diagnostics.md](references/validation-and-diagnostics.md);需要运行中恢复网络服务时再读取 [references/application-debugging.md](references/application-debugging.md)。
- 涉及 Linux 打包任务与 Windows 设备任务之间交接时,读取 [references/cross-host-coordination.md](references/cross-host-coordination.md)。
- 涉及简道云“研发组 -> 固件发布”记录填写、预填或核对时,读取 [references/firmware-release.md](references/firmware-release.md)。
只读取当前任务需要的参考文件。不要因为用户仅要求串口查看而加载远程打包流程。
## 默认环境
- SSH 配置主机:`emb.eapil.com`
- 远程工程搜索根:优先现有工作区、`~/work/develop/gitcode` 和实际挂载点;`/CamTells` 仅是兼容候选
- 远程工程搜索根:优先现有工作区、`~/work/develop/gitcode` 和实际挂载点;产品目录(如 `/CamTells`)只是候选之一
- 默认 Public 来源:`~/work/develop/gitcode/EMX2.1`
- 本地归档根目录:`F:\tmp\embedded-app-pc-debug`
- 串口默认值:`115200`、`8N1`、无流控
@@ -30,17 +32,19 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
## 工作方式
1. 根据用户请求确定需要执行的阶段;不强迫每次都完成整条链路。
2. 自己调查文件、脚本参数、网卡和设备响应等可查事实,只向用户询问真正需要选择的事项。新设备调试开始前先枚举串口并让用户选择串口或 Telnet;即使只有一个 USB 串口也不自动打开。串口必须取得本次用户确认的 COM 口;Telnet 必须先取得完整 SN 和型号。
2. 自己调查文件、脚本参数、网卡和设备响应等可查事实,只向用户询问真正需要选择的事项。新设备调试开始前先枚举串口并让用户选择串口或 Telnet;用户已明确选择串口时只确认尚缺的 COM 口,即使只有一个 USB 串口也不自行挑选。同一调试任务、目标和参数未变时复用已确认的 COM 口,不反复询问;更换设备、端口或目标不明确时重新确认。Telnet 必须先取得完整 SN 和型号。
3. 将每次完整任务归档到 `<根目录>\<项目>\<YYYYMMDD-HHmmss>`,分别保存 `firmware` 和 `logs`。
4. 搜索可以按型号、IP 或唯一响应列出候选,但这些条件都不能替代完整 SN。Telnet、升级及其他设备写操作必须以用户给出的完整 SN 精确匹配目标,并核对型号;多个候选或同一 SN 出现冲突时停止,不能默认第一台。
5. 先启动串口监测,再执行设备升级;串口输出同时实时显示并保存日志。需要在网页中查看日志或向设备输入命令时,使用单进程串口网页控制台,避免采集进程和发送进程争用同一个 COM 口。网页首次打开显示最新日志,所有发送内容都写入文本日志,空输入也必须发送真实 `CR`。刷新网页不能中断采集;多个页面共用服务时仅关闭最后一个页面才触发停止。最后页面关闭后,即使处于升级期间,也要在约 `5` 秒内结束采集、保留日志并释放 COM 口;之后必须重新启动控制台才能再次连接。
5. 先启动串口监测,再执行设备升级;串口输出同时实时显示并保存日志。默认使用可见的单进程串口网页控制台,先检查已有服务,正常则在同一页面复用,避免重复开窗口或启动第二个串口进程。网页首次打开显示最新日志,所有发送内容都写入文本日志,空输入也必须发送真实 `CR`。刷新网页不能中断采集;多个页面共用服务时仅关闭最后一个页面才触发停止。最后页面关闭后,即使处于升级期间,也要在约 `5` 秒内结束采集、保留日志并释放 COM 口;之后必须重新启动控制台才能再次连接。
6. 在 ROM 传输前集中显示目标 SN、型号、IP、当前版本、ROM 绝对路径、大小、SHA-256、可解析的目标版本、串口状态和所有无法验证的项目。
7. 只有用户在看到上述信息后明确确认升级,才能传输 ROM、烧录或触发重启。之前对搜索、编译或诊断的授权不能替代这次确认。
8. 升级失败后先诊断并保留证据,不自动重试;再次升级需要新的明确确认。
串口优先不等于自动选择。用户选择串口后,只有端口打不开、无数据、乱码或无法登录时才建议回退 Telnet;切换前告知用户,Telnet 连接前重新广播核对完整 SN、型号和当前 IP。网络异常但串口可用时可以在用户确认 COM 口后做只读检查;设备写入、停止进程、修改启动项或重启前还需确认该串口对应的目标 SN 和型号。固件升级不得绕过协议身份核验。
串口优先不等于自动选择。页面异常或无输出时,先按串口参考文档做本地只读诊断,设备静默不等于串口故障;确认端口打不开、占用或参数可疑后停手请用户核对,不反复重连。串口问题无法解决时可以建议 Telnet,但不能自动切换;切换前取得用户同意,Telnet 连接前重新广播核对完整 SN、型号和当前 IP。网络异常但串口可用时可以在用户确认 COM 口后做只读检查;设备写入、停止进程、修改启动项或重启前还需确认该串口对应的目标 SN 和型号。固件升级不得绕过协议身份核验。
用户如果只要求“显示串口”或“实时看日志”,仅打开只读串口监测;用户希望在页面持续查看时可以使用本地串口网页控制台,但不自动发送命令、搜索设备、登录、打包、传输文件或升级。
用户如果只要求“打开串口”“显示串口”或“实时看日志”,默认由助手打开可见网页并只读采集,不把执行启动指令转交用户,不自动发送命令、搜索设备、登录、打包、传输文件或升级。用户明确选择终端或其他串口工具时尊重其选择。打开、复用、持续连接检查与异常诊断详见 [串口参考文档](references/validation-and-diagnostics.md)。
网页日志必须支持拖选和 `Ctrl+C` 复制。选择期间只暂停日志区域绘制与自动滚动,不中断串口采集、日志保存或页面存活连接;取消选择后恢复刷新。选区保护与真实浏览器验收要求见串口参考文档。
### 串口登录与凭据获取
@@ -51,6 +55,16 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
- 登录失败一次后停止尝试,先核对当前提示阶段、键盘输入字符、设备型号/固件与配置来源。若无法由现有证据解释,不连续试密码,向用户报告并等待新的凭据或指示。
- 不将真实密码、密钥或 Token 写入本 skill、脚本、示例、摘要或交付表格。按当前调试约定,原始串口日志保留发送记录;在复制、上传或转发日志前,先提醒用户检查其中是否含有凭据,并遵循该目的地的授权要求。不得为了分享而覆盖或改写原始证据日志。
## 远程项目持续调试与日志/源码排查
- 对所有产品线和平台工程的问题,默认沿用当前已建立的 SSH PTY 会话和远程工程目录;执行新命令前先检查既有会话是否仍有效。会话有效时直接在原会话操作,不要重复登录或新开终端。持续调试、修改或构建任务尚未完成时,保持该 SSH 会话存活,不主动退出、关闭终端或切换到新会话;如果工具或网络限制导致会话可能超时,优先采用该会话支持的保活方式,并在等待期间避免发送会改变设备状态的命令。若连接因外部原因失效,再按配置主机重新连接,并明确告知会话已重建。
- 用户指定远程工程时,以该工程作为源码修改和构建的权威位置;本地仅保存必要的辅助工具、产物与证据,不另建源码副本作为实际修改入口。确需本地中转补丁时说明用途,应用到远端并核对差异后才能构建,不能把本地修改当作远端已生效。此规则适用于不同产品、芯片平台和项目目录结构,不以 CamTells 目录布局作为前提。
- 远程排查前先确认用户给出的项目路径、分支/工作区和目标文件。先读取 `git status --short` 识别用户或前序任务已有修改;只触碰本次授权范围内的文件,保留其他未提交修改,不自动清理、还原或覆盖。
- 用户要求“分析/排查”时先只读查日志和代码,不顺手修复。日志结论区分三类:日志直接事实、代码明确行为、尚未证实的推断;不要把同一时间出现的事件直接说成因果。
- 先核验日志文件的修改时间、日志内部日期和用户指定时间是否相符。再截取事件前后上下文并过滤高频周期日志,避免截断或周期噪声掩盖真正事件。若证据文件日期/时间段不符,指出缺少对应证据,并只做可由现有源码支持的有限判断。
- 代码改动遵守最小化原则,尊重原实现和调用顺序。先对照相邻代码,沿用类成员函数组织、命名、缩进、括号及格式化风格;已有逻辑按 `类名::函数名` 在类外实现时,不无故将同类职责分散为自由函数。新增代码说明职责、参数来源、关键分支及失败处理,不只逐行翻译语法。改动前后只检查目标文件 diff;不触碰其他现有修改。除非用户要求编译/验证,不擅自引入额外测试或部署;若未编译或设备验证,明确说明验证边界。
- 面向最终报告列出关键时间点、原始日志现象、对应代码路径、判断可信度、仍缺失的证据,以及是否修改/编译/部署。不要把“代码路径可能导致”包装成“日志已证实”。
## Git 与构建边界
- 不执行编译前 `git status` 检查,不因未提交修改阻止打包。
@@ -58,11 +72,11 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
- 默认执行 `EmxGitInfo` 并保存输出作为版本凭证;失败时记录原因。临时验证包可继续,正式发布是否继续由用户决定。
- 不使用旧 ROM 冒充本次产物。构建脚本若明确宣告 ROM 目标成功并验证了本次 ROM,之后仅辅助全量镜像步骤失败时,可以交付“仅供局域网升级的 ROM”,但不能声称完整发布包成功。
仅编译指定模块时,使用当前工程已确认的交叉工具链和模块构建目标,不强制走完整 ROM 打包流程,详见 [构建参考文档](references/build-workflow.md)。NFS 联调需保留用户配置,并核对实际运行文件、启动参数和依赖,详见 [应用调试参考文档](references/application-debugging.md);编译成功不能代替设备运行及功能验收。
## 工具脚本
- `scripts/v1_discover.py`:V1 UDP `4942` 搜索(`0x5A0EFF00/01`);可用 `--interface` 指定本机 IPv4。端口冲突时停止,不抢占进程。
- `scripts/v1_command.py`:V1 TCP `4941` 通用 CMD/Data 请求器;默认仅预检,所有命令只有显式 `--execute` 才发送。它不解析每条命令的业务语义,副作用命令必须先查对应协议并核对设备身份。
- `scripts/device_upgrade.py`:默认使用 V2 `30101` 身份预检;可用 `--protocol v1` 通过 V1 `0x5A0E2104` 核验后执行 EPFP/4001。V1 与 EPFP 的兼容性须由目标固件资料确认。缺少 `--execute` 时绝不连接升级端口或发送 ROM。
- `scripts/device_upgrade.py`:V2 `30101` 身份预检与 `EPFP/4001` 升级。缺少 `--execute` 时绝不发送 ROM。
- `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/型号。
@@ -79,3 +93,5 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
不要把连接建立、ROM 发送结束、下载进度达到 100% 或 Flash 计数达到总量单独称为升级成功。至少需要明确的协议成功终态,并继续核对同一 SN 的重启、重发现、网络恢复和目标版本;无法取得某项证据时明确说明,不补造结论。
串口网页控制台发生变更时,还要验证服务代码能编译、Skill 结构有效、COM 口由单一进程占用、服务实际连接、接收字节或日志文件持续增长、普通发送与空回车都有 TX 记录,并确认页面首次打开定位到最新日志。还必须完成生命周期验收:刷新不中断、两个页面关闭一个仍运行、关闭最后页面后约 `5` 秒内进程退出且状态接口离线、日志具有结束标记、原日志与原始字节文件仍在原路径,并且下一次服务能重新打开同一 COM 口。仅看到服务进程或空白网页不算验证通过。
上述串口行为验收适用于服务或页面实现变更;仅修改技能文档时检查结构、引用及规则一致性,不为文档校验打断现有采集。报告区分代码检查、替身单测、真实串口协议测试和实际浏览器操作验收;未执行的项明确标注,不以自动化结果代替完整界面验收。
+70 -14
View File
@@ -53,6 +53,9 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
tab-size: 4;
user-select: text;
-webkit-user-select: text;
cursor: text;
font: 13px/1.42 Consolas, "Cascadia Mono", "Microsoft YaHei UI", monospace;
}
.console {
@@ -176,6 +179,8 @@
let sendBusy = false;
let promptMode = 'unknown';
let pageClosing = false;
let livenessConnection = null;
let selectingLog = false;
commandPromptView.textContent = `${configuredPort} >`;
@@ -184,6 +189,29 @@
return `${path}${separator}client=${encodeURIComponent(clientId)}`;
}
function connectLiveness() {
if (pageClosing || livenessConnection || typeof EventSource !== 'function') return;
// 页面在后台时 setInterval 可能被节流;独立的原生持续连接才是页面存活依据。
// 它只维护当前页面的租约,不打开串口、不发送命令,也不会重启已经退出的服务。
livenessConnection = new EventSource(`${clientUrl('/api/client/live')}&token=${encodeURIComponent(token)}`);
livenessConnection.onerror = () => {
if (!pageClosing) refreshStatus();
};
}
async function fetchLocal(path) {
const controller = new AbortController();
// 请求失败后必须放开 polling 标志;否则一个悬挂请求会让页面永久停止更新。
const timeout = setTimeout(() => controller.abort(), 2500);
try {
const response = await fetch(path, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.arrayBuffer();
} finally {
clearTimeout(timeout);
}
}
// 仅清理终端显示控制字符,不修改设备输出中的普通调试内容。
function removeTerminalControls(text) {
text = text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '');
@@ -250,42 +278,57 @@
updateInputControls();
}
function isLogSelectionActive() {
// 拖选开始时选区可能仍为空,也不能替换文本节点;否则拖选会被轮询打断。
if (selectingLog) return true;
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return false;
for (let index = 0; index < selection.rangeCount; index++) {
if (selection.getRangeAt(index).intersectsNode(logView)) return true;
}
return false;
}
async function refreshLog() {
if (logPolling) return;
if (pageClosing || logPolling) return;
logPolling = true;
const nearBottom = logView.scrollHeight - logView.scrollTop - logView.clientHeight < 100;
const shouldFollowLatest = followView.checked && (!hasRenderedLog || nearBottom);
try {
const response = await fetch(`${clientUrl('/api/log')}&t=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
const bytes = new Uint8Array(await fetchLocal(`${clientUrl('/api/log')}&t=${Date.now()}`));
const fullText = removeTerminalControls(decoder.decode(bytes));
updateSessionState(fullText);
const visibleText = fullText.length > 500000
? `[前面的日志已在页面中省略,完整数据仍保存在文件中]\n${fullText.slice(-500000)}`
: fullText;
logView.textContent = visibleText || `日志文件当前为空,正在等待 ${configuredPort} 数据…`;
// 只暂停页面绘制,不停串口采集、文件落盘或页面存活连接。
// 必须在请求完成后检查选区,防止等待响应期间用户刚开始选择文字。
if (isLogSelectionActive()) return;
const nextText = visibleText || `日志文件当前为空,正在等待 ${configuredPort} 数据…`;
if (logView.textContent === nextText) return;
const nearBottom = logView.scrollHeight - logView.scrollTop - logView.clientHeight < 100;
const shouldFollowLatest = followView.checked && (!hasRenderedLog || nearBottom);
logView.textContent = nextText;
// 首次打开必须显示最新日志;等待浏览器完成布局后再滚动到底部,避免大日志仍停在开头。
if (shouldFollowLatest) {
requestAnimationFrame(() => {
logView.scrollTop = logView.scrollHeight;
if (!isLogSelectionActive()) logView.scrollTop = logView.scrollHeight;
});
}
hasRenderedLog = true;
} catch (error) {
logView.textContent = `${logView.textContent}\n[网页] 日志读取失败:${error.message}`;
if (!isLogSelectionActive()) {
logView.textContent = `${logView.textContent}\n[网页] 日志读取失败:${error.message}`;
}
} finally {
logPolling = false;
}
}
async function refreshStatus() {
if (statusPolling) return;
if (pageClosing || statusPolling) return;
statusPolling = true;
try {
const response = await fetch(`${clientUrl('/api/status')}&t=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const status = await response.json();
const bytes = await fetchLocal(`${clientUrl('/api/status')}&t=${Date.now()}`);
const status = JSON.parse(decoder.decode(bytes));
applyConnectionState(Boolean(status.isOpen));
connectionView.textContent = status.isOpen ? `● ${status.port} 已连接` : `● ${status.port} 已断开`;
connectionView.style.color = status.isOpen ? 'var(--ok)' : 'var(--danger)';
@@ -343,6 +386,10 @@
function disconnectClient() {
if (pageClosing) return;
pageClosing = true;
if (livenessConnection) {
livenessConnection.close();
livenessConnection = null;
}
const disconnectUrl = `${clientUrl('/api/client/disconnect')}&token=${encodeURIComponent(token)}`;
// 浏览器关闭标签页时普通异步请求可能被直接取消;sendBeacon 专门用于离页通知。
// 某些内置浏览器未实现 sendBeacon,因此同时保留 keepalive fetch 作为兼容兜底。
@@ -369,10 +416,16 @@
});
sendButton.addEventListener('click', sendCommand);
logView.addEventListener('pointerdown', (event) => {
if (event.button === 0) selectingLog = true;
});
window.addEventListener('pointerup', () => { selectingLog = false; });
window.addEventListener('pointercancel', () => { selectingLog = false; });
window.addEventListener('blur', () => { selectingLog = false; });
followView.addEventListener('change', () => {
if (followView.checked) {
requestAnimationFrame(() => {
logView.scrollTop = logView.scrollHeight;
if (!isLogSelectionActive()) logView.scrollTop = logView.scrollHeight;
});
}
});
@@ -408,9 +461,12 @@
window.addEventListener('beforeunload', disconnectClient);
window.addEventListener('pageshow', () => {
pageClosing = false;
connectLiveness();
refreshLog();
refreshStatus();
});
connectLiveness();
refreshLog();
refreshStatus();
setInterval(refreshLog, 800);
+22
View File
@@ -33,6 +33,13 @@ mount -t nfs <服务器IP>:/nfs/<用户名> /mnt/nfs -o nolock
7. NFS 已挂载时必须与本次确认的服务器和导出路径完全一致。源程序必须通过 ELF 架构和解释器检查;设备缺少 `readelf` 时默认停止,只有人工看过有限检查结果后才可显式放行。
8. 在线停止已有进程时必须提供从当前设备实查得到的失败恢复命令。脚本会在替换失败时尝试恢复原文件和原进程,并在设备 `/tmp/codex-module-<模块>-<动作>-<时间>.state` 记录事务阶段;出现命令超时、会话中断、掉电或 `ROLLBACK_FAILED` 时状态未知,禁止直接重试,必须重新核对设备身份后读取事务状态,检查进程、目标哈希和备份,不能宣称已自动恢复。
### NFS 联调的配置与运行证据
- 替换前记录现有程序路径、PID、启动参数、配置工作目录、必要环境开关、动态库来源和恢复方式。用户手工调整的上线条件、持续运行设置、服务器地址等配置保持不变;不能因重新部署二进制而用工程默认文件覆盖。仅在用户明确要求修改某字段时最小化更改该字段,记录配置哈希与差异,不连带修改其他设置。
- 新版本沿用当前任务确认的启动方式和配置目录;配置依赖相对路径时,不能只切换到 NFS 程序目录启动。核对动态加载器、库路径、必要 `.so` 和符号链接,不能因文件已复制或程序短暂出现就认为依赖齐全。
- 启动后记录 PID,并在可用时通过 `/proc/<pid>/exe`、`cmdline`、`cwd` 核对实际运行文件、参数和工作目录;计算对应部署文件的 SHA-256,必要时检查 `maps` 中实际加载的库来源。不能仅凭进程名、旧日志标记或启动命令返回 0 判断新程序已生效。
- 观察常驻进程持续存活及目标业务结果,区分构建、拷贝、启动和功能验证的结论。已有配置未要求变更时核对前后哈希一致;未验证的功能明确列为待验证,不扩张为整机或全部业务通过。只重启明确授权的模块,不把整机重启或其他关键服务重启作为默认联调步骤。
## DxMain 与 daemon.sh
不要在 `daemon.sh` 或其后台软件看门狗仍运行时停止 `DxMain`。守护逻辑可能重新启动 `DxMain`,关键服务丢失时还可能执行强制重启。
@@ -59,6 +66,21 @@ mount -t nfs <服务器IP>:/nfs/<用户名> /mnt/nfs -o nolock
5. 重启前串口必须已经打开并确实收到数据,同时给出可以通过串口执行的恢复命令。完成修改不等于取得重启授权;集中展示变更、备份和预期后等待明确的“确认重启”。
6. 重启后通过串口观察完整启动过程,核对目标进程、守护进程、工厂程序、异常退出、panic、看门狗和循环重启;网络恢复后再使用协议或 Telnet 辅助验证。
## 运行中恢复网络服务
网卡在开机阶段缺失时,依赖它的服务可能已经退出,也可能作为其他主进程内的线程存在。不要只用 `ps | grep <服务名>` 判断服务是否存在;同时检查启动日志、监听端口、Launcher 配置和当前工程实现。
在已经运行的整机上单独启动或重启 NetServer 等网络服务前,先读取 `daemon.sh`、WatchDog 逻辑和关键进程关系。消息总线重连、关键服务短暂断开或守护检测可能触发整机重启。未经明确授权不要用在线重启服务试探;需要恢复升级通道时,优先采用影响范围更小且可回退的临时步骤,例如加载已验证驱动、恢复已确认的产品 MAC、关联和 DHCP。
临时网络恢复也要遵守以下边界:
1. 先通过串口确认设备身份、当前版本和产品网络配置,再执行会改变接口状态的命令。
2. 加密网络 param 只在唯一 `/tmp` 副本上解密;从副本提取当前设备已有的 SSID、PSK 和 MAC,不把明文打印到终端摘要、任务消息或 skill。
3. 只创建临时 wpa_supplicant 配置,任务结束时删除;需要持久写回配置时按 param 修改流程重新取得授权。
4. 记录每个阶段的证据:驱动加载、接口创建、MAC、关联、租约、路由、监听端口和 PC 可达性。
5. 临时恢复后的升级仍必须通过标准协议身份预检;不得把串口身份作为跳过 `30101` 的理由。
6. 新固件升级后必须重启验证自动恢复;如果还需要任何手工步骤,修复不能判为通过。
## 烧录状态与工厂模式
未烧录设备可能自动进入产测流程;已烧录设备通常进入正常流程,只有强制工厂标志生效时才运行产测程序。具体判断可能使用 MAC/SN 文件、文件大小、加密烧录数据、环境变量或标志文件,必须从当前工程和设备的 `function.sh`、`appinit.sh`、`allstart.sh` 实查。
+12 -4
View File
@@ -1,13 +1,13 @@
# 远程打包与产物归档
仅在任务涉及编译新固件或下载远程产物时读取本文件。
仅在任务涉及远程编译(完整固件或单模块)或下载远程产物时读取本文件。
## 默认值与覆盖
| 项目 | 默认值 |
|---|---|
| SSH Host | `emb.eapil.com` |
| 工程搜索根 | 当前远程工作区、`~/work/develop/gitcode`、实际挂载点;`/CamTells` 仅作兼容候选 |
| 工程搜索根 | 当前远程工作区、`~/work/develop/gitcode`、实际挂载点;可把已知产品目录(例如 `/CamTells`)作为候选之一,不限于该目录 |
| Public 来源 | `~/work/develop/gitcode/EMX2.1` |
| 本地归档根目录 | `F:\tmp\embedded-app-pc-debug` |
@@ -16,10 +16,10 @@
## 工程定位
1. 先用现有 SSH/VS Code 配置解析 Host,不猜测用户名、端口或私钥路径。
2. 优先使用用户给出的绝对路径。只给项目名时,从 VS Code 最近工作区、`~/work/develop/gitcode`、`/CamTells` 和实际挂载点按目录名精确搜索;搜索深度至少覆盖从用户目录起 12 层,并为每个根设置独立合理超时。
2. 优先使用用户给出的绝对路径。只给项目名时,从 VS Code 最近工作区、`~/work/develop/gitcode`、实际挂载点和已知产品目录按目录名精确搜索;`/CamTells` 是可选候选,不代表其他产品工程的位置。搜索深度至少覆盖从用户目录起 12 层,并为每个根设置独立合理超时。
3. PowerShell 调用远端 Shell 时,避免让本地提前展开远端 `$HOME` 等变量。记录每个搜索根的退出码并明确区分:没有匹配、无权访问、路径未挂载、命令不存在和搜索超时。超时不等于项目不存在。
4. 如果有多个候选,列出远程绝对路径等待选择;不要凭目录修改时间挑选。
5. 确认工程目录包含待用脚本,再确定真实命令行参数。类似 `product/CamTells/EPM21C` 的深层产品目录不能因浅层 `find -maxdepth` 被排除。
5. 确认工程目录包含待用脚本,再确定真实命令行参数。产品目录可能较深,例如 `product/<产品线>/<项目>`;不能因浅层 `find -maxdepth` 排除有效工程。
## 构建顺序
@@ -31,6 +31,14 @@
6. 尊重构建脚本内部的校验与重建循环。脚本因随机命中、签名或封装检查而自动重建时,让脚本自行完成;不要在外层重复启动、重复改版本或把一次内部重建误报为多个发布版本。
7. 构建失败时不下载旧产物。若本次 ROM 已由脚本明确生成、校验通过,而随后仅全量烧录镜像或其他辅助目标失败,应分开报告:允许交付已验证 ROM 用于局域网升级,但完整发布包仍判为失败或不完整。
## 单模块交叉编译与 NFS 交付
用户只要求编译某个应用或库时,不强制执行完整固件的 `product.py`、`release.py` 流程。优先复用当前远程工程已验证的构建目录和目标,核对其源码目录、交叉工具链、目标架构及必要构建选项;缓存指向其他工程或工具链时先说明冲突,不直接覆盖或继续编译。
记录实际构建目标、命令、退出码、本次产物的绝对路径、大小、SHA-256 和 ELF 信息。按运行需求交付必要动态库及其链接关系,不把静态库、开发头文件或旧目录中的同名文件一概当成设备运行依赖。
用户指定 NFS 导出目录时,在该目录下使用独立的本次产物目录,保留旧程序和依赖;避免覆盖设备正在从 NFS 执行的二进制。拷贝后核对源与目标的哈希及执行权限,并明确区分“远端构建完成”“NFS 产物已就绪”“设备已运行”“功能验证通过”。设备验证步骤见 [应用模块调试](application-debugging.md),不能因完成拷贝就宣称验证通过。
## 打包任务交付清单
Linux 打包任务应一次性交付以下事实,供 Windows 设备任务直接使用:
+12
View File
@@ -23,6 +23,18 @@
- 读取不到任务正文时,先核对任务 ID 和主机 ID,再查看最新任务状态。只有任务工具确实不能返回内容时,才在已授权主机上读取对应会话记录或请用户转交摘要。
- 任务 ID、主机 ID、项目路径和远端临时目录都属于本次运行参数,不写成通用固定值。
用户给出 `codex://threads/<UUID>` 并要求直接向原会话发送时,优先使用会话消息能力。若本机执行 `codex queue --thread <UUID> --message <内容>` 返回 `no rollout found`,这只说明当前 app-server 的本地线程存储没有该 rollout,不代表 UUID 无效,也不能改为新建会话。
确认目标 rollout 实际驻留的已授权主机后,可以通过现有 SSH 配置在该主机执行同一条 `codex queue`:
```text
codex queue --thread <UUID> --message <完整任务消息>
```
只在命令明确返回 `Queued message <message-id> for thread <UUID>` 后报告送达,并保留 message ID。打开线程页面、导航成功、命令退出但无确认、读取结果为空或中继已收到,都不能冒充原会话已收到消息。消息入队只证明投递成功,不证明对方已执行、构建完成或产物可用;后续状态仍需从原会话或产物证据确认。
直接投递消息应包含工程路径、已确认事实、要求的修改与验证、产物交付字段和禁止操作边界。不要把某次线程 UUID、SSH 主机、设备身份或工程路径写成固定值,也不要在参数中暴露密码、PSK 或 Token。
## 最小交接消息
```text
+49 -25
View File
@@ -11,43 +11,65 @@
- `C:\Users\ZK\Downloads\设备端TCP文件传输类协议.md`
- `C:\Users\ZK\Downloads\工厂协议V1.md`
- `E:\CodeSpace\git-pro\eapil_qt_player_v2\EpProtocolDemo`
- `E:\CodeSpace\git-pro\eapil_public_library\EapilPanoC\src`(V1 命令、端口、数据结构和 Qt 客户端实现;以当前源码为实现依据,部署设备仍需实测)
## 协议资料总览
## V1 协议(EapilPanoC)
本技能覆盖的协议资料不止设备搜索和升级。按当前本机资料,范围包括:
V1 与 V2 使用不同端口和帧格式,不能把 `EPBP/EPTP/EPFP` 的 V2 帧头套用到 V1 通用配置接口。
| 协议族 | 资料范围 | 本技能的使用方式 |
| 功能 | 传输与端口 | 请求/响应格式 |
|---|---|---|
| V1 工厂协议 | 66 条工厂/产测命令;UDP 搜索与 TCP 控制 | `scripts/v1_discover.py` 搜索;`scripts/v1_command.py` 对文档中任意 CMD 发送 JSON 请求 |
| V2 广播发现 | EPBC/EPBD、UDP 8941/8951、cmd 60000 | `scripts/v2_discover.py` |
| V2 统一配置 | cmd 30101 起,覆盖基本信息、网络、音视频、音频、设备操作、SD 卡、夜视、时间、OSD、IO 等文档章节 | TCP 8942;当前脚本仅做身份查询,其他命令按文档单独实现/调用;不得把语义协议误当作固定传输协议 |
| V2 产测 | cmd 1–26,LED、按键、老化、带宽、PCBA、屏幕、MIC、SD、芯片、镜头等 | 文档定义命令语义;需使用匹配固件提供的传输封装 |
| TCP 文件传输 | EPFP,2001/2002/3001/4001,端口 8943 | `scripts/device_upgrade.py` 实现 4001 固件升级;其余命令保留文档语义,不由该脚本执行 |
| TCP 音视频播放 | 端口 8944,cmd 0x01–0x05 | 长连接心跳、解码器列表、播放控制及媒体帧;不是设备发现或升级通道 |
| V2 工厂整合协议 | 工厂扩展命令及设备信息接口 | 按资料版本与设备支持情况使用;与 V1 工厂命令空间不能混用 |
| 局域网搜索 | UDP `4942`,向广播地址 `4942` 发送并在本地 `4942` 接收 | `uint32 cmd + uint32 JSON长度 + UTF-8 JSON` |
| 通用配置获取/查询 | TCP 设备端 `4941` | `uint32 cmd + uint32 payload长度 + payload`;常见无参读取长度为 0;响应为 `uint32 cmd + uint32 JSON/数据长度 + payload` |
| 固件升级 | TCP 设备端 `4941` | `uint32 cmd + uint32 长度 + 32字节ASCII小写MD5十六进制文本 + ROM原始字节`;长度是 MD5 文本与 ROM 字节数之和 |
“覆盖”指协议字段、命令范围、边界和现有脚本能力都在参考文档中可查;不代表每种协议都有通用执行器,也不代表所有固件都启用了每条命令。新增写操作前须逐条查对应协议的请求、响应和副作用。
V1 多字节整数由源码中的 `QDataStream` 默认网络字节序编码(大端)。TCP 是字节流,必须先累积完整 8 字节响应头,再按头内长度读取完整 payload;不能假定一次 `recv` 返回一整帧。JSON 请求/响应的长度不包含 8 字节头。搜索响应也必须校验命令、长度和请求 `session`。
## V1 工厂协议
### V1 搜索
V1 使用独立于 V2 的报文,不带 `EPBP`/`EPTP`/`EPFP` 同步字:
源码:`EpSearchPanocsInLan.cpp`、`json/SerachCameraRequest.cpp`、`json/SerachCameraResponse.cpp`。
```text
uint32 CMD (network byte order)
+ uint32 Data length (network byte order)
+ Data bytes (normally UTF-8 JSON; optional)
请求命令 `0x5a0eff00`,响应命令 `0x5a0eff01`。请求 JSON 至少带随机 `session`:
```json
{"session":12345}
```
| 功能 | 传输 | 端口 | 命令 |
|---|---|---:|---|
| 设备发现请求/响应 | UDP 广播 | 设备 4942 | `0x5A0EFF00` / `0x5A0EFF01` |
| 工厂/产测控制 | TCP | 4941 | 按 V1 文档中各接口的 CMD |
响应常见字段:`session`、`ip`、`dev_type`、`sn`、`mac`、`firmware`、`hardware`、`dev_id`、`dev_ver`,部分设备另有 `ipv6`、`wifi_ssid`、`wifi_quality`。按回显 session 过滤本轮响应。搜索会列出广播域中的多台设备;不得把第一个响应当目标。升级或其他写操作仍要求完整 SN 与型号核验。若设备 SN 为空,只能将搜索结果用于只读信息查询;SN 空值不构成设备身份核验,不能据此绕过写操作的身份保护。
搜索请求 Data 为 `{"session":<随机整数>}`。只接受 `0x5A0EFF01` 且回显本次 session 的响应。设备响应 JSON 保留原始字段;协议版本之间字段可能不同,型号、SN、IP、MAC 等信息必须根据实际响应展示,缺失即标为不可用。多网卡或设备搜索工具已占用 UDP 4942 时,先确定本机接口/端口状态;不结束占用进程。V1 搜索用 `scripts/v1_discover.py`,对单个接口显式指定 `--interface <本机IPv4>`。
### V1 通用配置读取命令
`scripts/v1_command.py` 是通用 TCP 请求器:指定 IP、CMD 和 JSON Data;默认仅显示预检,不建立连接。只有显式 `--execute` 才发送。它校验 TCP 响应头、长度和响应 CMD,但不解释各命令的业务成功字段、不判断写操作安全性,也不执行设备身份核验。因此必须先将发现的设备与完整 SN/型号人工核对;对写配置、复位、格式化、烧号或其他有副作用命令,先核实请求字段及后果并取得本次操作确认。不得用该通用脚本发送 ROM。
以下命令来自 `EapilPanoCHead.h` 的 V1 `MessageCMDType`,对应 `EpPanoCConfigure.cpp` 的 getter/query 实现。大部分请求使用空 payload;按返回命令和 JSON `code` 判定设备是否支持,`code=0` 表示设备成功应答,不代表每个可选字段都存在。
V1 工厂协议文档列出的 66 条命令及字段定义是命令语义来源。传输层客户端可承载这些 CMD,但各命令是否被具体型号/固件实现,仍需用对应版本文档或设备响应确认。V1 示例工程只实现了搜索和前五个 TCP 示例命令,不代表其它 V1 命令已在设备上验证。
| 项目 | 命令 |
|---|---:|
| 获取 SN | `0x5a0e2100` |
| 获取模板 | `0x5a0e2102` |
| 获取基本信息 | `0x5a0e2104` |
| 获取圆心坐标 | `0x5a0e2110` |
| 查询视频参数范围 / 获取视频参数 | `0x5a0e2120` / `0x5a0e2121` |
| 查询音频参数范围 / 获取音频参数 | `0x5a0e2130` / `0x5a0e2131` |
| 查询 ISP 参数范围 / 获取 ISP 参数 | `0x5a0e2140` / `0x5a0e2141` |
| 获取缩略图 | `0x5a0e2150`(二进制 JPEG,不按 JSON 解析)|
| 获取 SD 卡信息 | `0x5a0e21a0` |
| 获取网络信息 | `0x5a0e21b0` |
| 获取 Wi-Fi 信息 | `0x5a0e21c0` |
| 获取时间信息 | `0x5a0e21d0` |
| 获取夜视信息 | `0x5a0e21e0` |
| 获取浪涛兼容 ID | `0x5a0e3100` |
| 获取大拿兼容 ID / danale.conf | `0x5a0e3200` / `0x5a0e3210` |
兼容平台接口可能返回“不支持平台/命令”;要记录为设备能力结果,不要当作传输层故障。`0x5a0e3210` 返回配置内容,可能包含敏感字段;仅在用户任务确需时调用,控制原始结果的保存、展示和转发范围。V1 还定义工厂/扩展类读命令(如 PTZ、MAC、IMEI、老化测试、设备密码等),不属于上述通用配置接口;有的需要特定参数,有的会暴露敏感信息。不要用零参数盲发或把它们当作通用 getter。先从目标型号对应实现核对参数、返回结构和敏感性,再按明确任务范围调用。绝不调用 SET 命令来“探测”支持能力。
### V1 固件升级
V1 升级命令为 `0x5a0e2170`,TCP 端口 `4941`。实现依据 `EpConnection::sendTcpFile`:计算 ROM MD5 并转为 32 字节 ASCII 十六进制文本,帧长度字段等于 `32 + ROM字节数`,随后发送 MD5 文本及 ROM 内容。响应仍按 V1 `cmd + length + payload` 解析,成功 JSON 示例为 `{"code":0,"desc":"update success"}`。这不是 V2 `EPFP/4001` 的状态轮询协议;不得把 V2 的 `status=2` 要求硬套到 V1。V1 成功应答后仍须检查串口重启、网络恢复、重新搜索与目标版本回查;缺项必须明确标为未验证。
执行升级前仍遵守本文“升级前保护”与主技能的用户确认要求。已有工具若只支持 V2,不能改参数伪装成 V1;应使用明确实现 V1 的客户端,并保留 ROM 路径、大小、MD5、SHA-256、目标 IP、型号、升级响应及升级后版本证据。完整 SN 缺失时不得以型号/IP 替代身份核验,也不得执行写操作。
### V1 现场验证样例(C4662)
2026-10-21 对一台 C4662 的实测仅作为该设备/固件的实例,不代表所有 V1 设备字段恒定:V1 搜索和基本信息返回 `dev_ver=2.0.20261021.1`、`firmware=1.0.4`、`dev_type=C4662`,但 SN 为空。常见 getter 中视频、音频、ISP、SD、网络、时间、夜视、缩略图和大拿 ID 接口有成功响应;模板、浪涛兼容接口和 danale.conf 在该设备返回失败或不支持。成功但无数据字段的响应(例如 Wi-Fi 信息)应如实记录,不推测配置值。
## V2 协议矩阵
@@ -74,7 +96,7 @@ V2 发现请求 JSON:
- `outer.type`
- `hardware.type`
非关键网络字段允许缺失。完整 SN 是升级身份核对的首要依据。V1 的端口与帧格式必须从 V1 文档或已验证的 `EpProtocolDemo/V1` 获取,不能套用 V2。
非关键网络字段允许缺失。完整 SN 是升级身份核对的首要依据。V1 的端口与帧格式见上文 V1 章节,并以目标设备对应的 V1 文档或源码复核;不能套用 V2。
执行发现时使用 `scripts/v2_discover.py`,并遵守以下规则:
@@ -137,6 +159,8 @@ uint8 status
- 多个 ROM 或设备候选时等待选择。搜索端口被占用或没有完成完整 SN 核对时不得升级。
- 确认后调用 `scripts/device_upgrade.py --execute`,不绕过其保护参数。
串口登录可以确认现场设备、读取版本并恢复网络,但不能替代升级前的 `30101` 身份响应。即使 COM 口已由用户确认且串口内的 SN、型号看似匹配,`30101` 超时、返回不完整或不匹配时仍不得发送 ROM。先检查设备端端口监听、接口 MAC、关联、DHCP、PC 多网卡选路和二层可达性;恢复后重新运行标准预检。
## 已知协议歧义
- 状态章节没有明确说明每次状态是否带 EPFP 外层头,因此客户端兼容完整帧和裸状态。
+25
View File
@@ -0,0 +1,25 @@
# 简道云固件发布记录
当用户要求预填、填写或核对“研发组 -> 固件发布”记录时使用本流程。以用户指定的固件包目录和当前项目工程为事实来源,不照抄上一次发布记录。用户若只授权预填,填写后核对字段即可;未经明确授权,不点击“提交”或“保存草稿”。
## 确定本次发布资料
1. 从用户提供的固件包路径检查当前版本目录及其现有资料,确认项目、版本、芯片/Sensor 等可以由资料直接核实的信息。不要用历史发布记录代替当前包内资料;有冲突时指出冲突,只询问无法从工程或发布资料查明的事项。
2. 定位对应项目工程和工厂发布目录。版本差异的起点从该工厂目录 `change.log` 中读取最新时间点,不从上一个表单记录或手工猜测日期。
3. 在项目工程目录运行系统环境可调用的 `git_history_by_date.py -d "<change.log 最新时间点>"`。脚本通常从 PATH 提供;不要假定脚本位于工程目录。保留脚本给出的扫描结果、项目目录标题、分组、顺序和提交说明。不得把按仓库目录组织的结果摊平、重排、遗漏扫描到的变更仓库,或用手工挑选记录代替扫描结果。
4. 将脚本原始输出作为“相比上个版本的所有 git 提交记录”的依据。保留仓库目录分组和原有提交记录顺序;可清理明显的显示格式问题,但不得改写提交事实或把不同仓库的记录合并成一个主题列表。无提交的项目不自行补入。
5. 填写 `git-repo版本信息` 等当前版本字段时,以当前工程和版本资料为准,并按该字段说明运行相应工程脚本(例如 `EmxGitInfo`);不要把历史表单中的仓库版本复制成本次版本。
## 归纳“所有影响到的功能项”
1. 检查当前项目对应的功能清单(表单提示的《功能清单》或项目工程内实际使用的清单),结合本次扫描到的提交说明和受影响文件/配置,列出所有有依据的功能范围。不得只参考上一条发布记录已有的影响范围,也不得把其他产品的功能清单套用到当前项目。
2. 把工程术语整理成测试人员可识别的功能名称和可观察行为。每项应能对应到本次提交或配置变化;提交说明只有配置默认值变化时,描述使用该默认值时受影响的行为,不扩大成未经证实的功能改动。产品特有的功能名称只在当前项目证据支持时使用,不写成通用规则。
3. 对配置、依赖、构建文件、发布元数据等非直接业务功能改动,说明它们实际影响的配置加载、运行依赖、版本追溯等范围;若证据不足以确定设备侧功能影响,就说明为“版本/发布记录维护”等有限范围,不能臆造业务影响。
4. 影响范围可按测试理解需要编号,但不能因此改变 Git 扫描记录原有的目录分组和顺序。检查是否覆盖每个相关仓库的功能改动,并排除扫描区间外的旧记录。
5. 对不能从工程、功能清单或提交证据确认的影响保持克制;将推断明确标成待核实,或不写入影响项。若用户要求先预填供审核,可填写有证据的基础范围并指出无法确认的事项,不阻断已能完成的字段。
## 填表与结束
- 仅编辑用户指定的字段;根据当前固件包和工程资料填写,不用历史版本的数值填充未知项。
- 输入多仓库 Git 记录时保留扫描器原始目录分组;注意长字段可能需要确认内容未被截断。
- 预填完成后重新读取表单字段,确认文本内容和换行完整。用户只授权预填时,不点击“提交”或“保存草稿”,并在回复中明确说明记录仍未提交/保存。
+75 -8
View File
@@ -4,22 +4,55 @@
## 串口准备
1. 每次串口任务先请用户确认当前 COM 口;可以枚举 Windows 串口及描述帮助确认,但不要沿用上一次的端口或自行挑选。
1. 新设备调试先取得本次用户确认的 COM 口,可以枚举 Windows 串口及描述帮助确认,但不自行挑选。同一调试任务、设备和参数未变时沿用已确认的端口,不因执行下一条命令或恢复页面而反复询问;更换设备、端口或目标不明确时重新确认,不从其他任务或历史记忆猜测端口。
2. 默认 `115200`、8 数据位、1 停止位、无校验、无流控,允许覆盖。
3. 打开失败、打开后没有输出、乱码或访问拒绝时立即停手,请用户核对 COM 口、波特率/校验/数据位/停止位/流控、端口是否已释放,必要时重新插拔或更换转接器。不要强制结束 SecureCRT、串口工具或其他进程。
4. 只读采集使用 `scripts/serial_monitor.ps1`;需要网页实时显示或网页命令输入时,使用 `scripts/serial_web_console.ps1`。确认 COM 口已打开并开始记录后,才传输 ROM。
3. 页面异常、打开失败、无输出或乱码时,先按下文“异常时的本地只读诊断”定位层次;确认端口打不开、占用或参数可疑后停手,请用户核对 COM 口和参数。不要强制结束 SecureCRT、串口工具或其他进程,不反复重连或自行重启设备。
4. 默认使用 `scripts/serial_web_console.ps1` 打开可见网页,即使只读查看也使用该控制台;需要用户明确选择的终端只读采集时可使用 `scripts/serial_monitor.ps1`。确认 COM 口已打开并开始记录后,才传输 ROM。
5. 串口网页控制台必须由同一个进程独占 COM 口并完成收、发和日志写入,不能再启动第二个采集或发送进程。服务只绑定 `127.0.0.1`,同时写本任务自己的文本日志和原始字节文件。调试信息不脱敏。
6. 不主动读取、复制或上传 SecureCRT 的历史日志。只有本 skill 在本次任务生成的文本和原始字节日志可以默认使用;其他工具日志需用户明确授权。
## 打开与复用
1. 先确认当前任务已有的服务地址、目标 COM 口和日志归档;读取已有服务的 `/api/status`,核对 `port`、`isOpen`、`lastError`,不因为 PowerShell 进程或浏览器标签存在就认定串口可用。启动脚本本身不负责发现与复用旧服务,助手必须先检查再决定是否启动。
2. 服务正常且端口匹配时继续使用同一个页面、服务和日志,不重复运行启动脚本,不另起发送/采集进程。需要显示已有页面时优先展示原标签,不每执行一条命令就新开窗口;服务属于其他任务或端口不匹配时不能直接接管。
3. 确认没有可复用的服务后,检查 `serial_web_console.ps1` 参数说明,以 `powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File <脚本绝对路径> -Port <已确认COM口> -LogPath <本任务日志绝对路径> -WebPort <本地端口>` 启动。使用 Windows PowerShell 5.1,不使用 `pwsh.exe`;不修改系统执行策略,后台辅助进程隐藏窗口。COM 口、网页端口和日志目录取当前任务配置,不固化本次设备的值。
4. 由助手将服务实际地址打开为可见网页,向用户给出地址和日志路径;已有标签指向该服务时复用原标签。只读检查 `/api/status` 的 `activeWebClients`、`liveWebClients`,再在设备有输出时观察 `receivedBytes`、`lastReceiveUtc` 和日志增长。`activeWebClients` 是已登记页面租约数,`liveWebClients` 是持续连接数,均不能证明用户正在看着窗口。
5. 自动化读取 `/api/status`、`/api/log` 时不附加 `client` 参数,不伪造页面持续连接或额外心跳。页面负责自己的在线登记,后台调试脚本不能阻止最后页面关闭后退出。网页持续连接只用于页面在线判断,不代表日志完全通过该连接推送。
6. 服务正常时不为验证而重启。服务确已退出且用户仍要求继续当前串口任务时,可按已确认参数重新启动并在原标签刷新,明确说明采集中断;不能在用户关闭最后页面后擅自重启或循环重连。重新启动使用新的日志文件,保留旧文本与原始字节文件。服务会在启动时读取 HTML 模板,只有已重启服务后刷新页面才会取得新模板和会话令牌;仅修改磁盘 HTML 后刷新旧服务不能加载更新。
## 实时输入与页面生命周期
只读串口脚本只负责监测。网页控制台允许用户直接输入命令,页面按 Enter 后固定追加 `CR` 并立即发送,不做危险命令二次确认。命令框为空时也发送 `CR`,表现为真实回车。服务接口仍支持无结尾、`CR`、`LF`、`CRLF`,供明确的自动化流程调用。AI 仍只能在用户明确要求或确认具体命令后代为发送,不得自行发送登录名、密码、Shell 命令、break、DTR 或 RTS 脉冲。所有写入数据都在文本日志中记录为 `[TX:来源]`,不可见字符显示为 `<CR>`、`<LF>` 等;RX 文本日志和原始字节日志保持不变。
网页输入区保持在日志下方,只保留快捷填入、命令框和发送按钮,不显示行尾选择、独立空回车、历史下拉、清空、密码开关、U-Boot 连续发送按钮及交互状态栏。页面首次打开时默认定位到最新日志;启用自动滚动后持续跟随最新输出,用户手动向上查看旧日志时不抢回位置,再次勾选自动滚动立即回到底部。Enter 发送、Shift+Enter 输入多行、上下方向键浏览当前页面的隐式历史、Esc 清空输入;处理中文输入法组合状态,确认候选时不能误发串口命令,并阻止长按 Enter 连续发送空回车。发送请求未完成时阻止重复提交,成功后立即刷新 TX 日志并保持输入焦点。页面识别 `Password:` 阶段并自动遮挡输入,而且密码不加入页面命令历史。按用户要求,实际发送内容仍会写入 TX 文本日志。
网页输入区保持在日志下方,只保留快捷填入、命令框和发送按钮,不显示行尾选择、独立空回车、历史下拉、清空、密码开关、U-Boot 连续发送按钮及交互状态栏。页面首次打开时默认定位到最新日志;启用自动滚动后持续跟随最新输出,用户手动向上查看旧日志时不抢回位置,无日志选区时再次勾选自动滚动立即回到底部。Enter 发送、Shift+Enter 输入多行、上下方向键浏览当前页面的隐式历史、Esc 清空输入;处理中文输入法组合状态,确认候选时不能误发串口命令,并阻止长按 Enter 连续发送空回车。发送请求未完成时阻止重复提交,成功后在选区保护允许时刷新 TX 日志并保持输入焦点。页面识别 `Password:` 阶段并自动遮挡输入,而且密码不加入页面命令历史。按用户要求,实际发送内容仍会写入 TX 文本日志。
每个网页标签维护独立客户端心跳。最后一个页面正常发送关闭通知后等待约 `2` 秒,若没有刷新后的新页面接管,则服务自动结束日志采集、写入结束标记并释放 COM 口;多个标签同时打开时,关闭其中一个不能停止其他页面。页面异常退出或内置浏览器不触发离页通知时,心跳超过 `3` 秒后清理失联页面,再进入 `2` 秒宽限期,因此最后页面关闭后最迟约 `5` 秒退出。此规则同样适用于升级期间:需要全程监测时必须保持至少一个页面打开。服务启动后尚未打开过网页时不要自动退出。
### 日志文本选择与复制
- 日志区允许浏览器原生拖选、双击选择和 `Ctrl+C` 复制,不拦截复制快捷键,也不因复制触发串口发送。
- 拖选从鼠标按下开始保护,即使此时选区仍为空;拖选结束后,只要非空选区与日志区相交,就不替换日志文本节点或执行自动滚动。选择其他区域不应暂停日志。鼠标释放、取消或窗口失焦时清除拖选状态,但仍保留非空选区的保护。
- 定时读取不能无条件重建整段正文:内容相同不改 DOM,存在选区时延后绘制。网络响应返回后、延迟布局帧执行前均重新检查选区,读取失败提示也不能覆盖选中的正文。否则用户在等待请求或布局期间开始选择,仍会被下一次更新打断。
- 选择期间只暂停日志区域绘制与自动滚动,状态读取、串口采集、文本/原始字节落盘及页面存活连接继续。取消选择后,下次刷新展示最新数据,并按原自动滚动与手动查看位置规则处理,不用关闭串口来冻结页面。
### 页面存活与关闭
每个网页标签维护独立的 EventSource 持续连接,服务端每秒写入一次探测;页面进入后台、定时轮询被节流时,不因缺少轮询而误停串口。最后一个页面正常发送关闭通知后等待约 `2` 秒,若没有刷新后的新页面接管,则服务自动结束日志采集、写入结束标记并释放 COM 口;多个标签同时打开时,关闭其中一个不能停止其他页面。页面异常退出或内置浏览器不触发离页通知时,由持续连接的写失败清理该页面,再进入 `2` 秒宽限期,实测验收仍要求最后页面关闭后约 `5` 秒内退出。不支持 EventSource 的页面退回原有 `3` 秒轮询心跳超时,日志必须明确写为心跳超时,不能直接断言用户关闭了页面。此规则同样适用于升级期间:需要全程监测时必须保持至少一个页面打开。服务启动后尚未打开过网页时不要自动退出。
关闭后的文本日志和原始字节文件保留在原路径,不自动移动、删除或截断。服务退出后原网址不可用,不部署常驻管理服务,也不在再次访问时自动打开串口;需要继续查看时重新运行启动脚本。关闭通知只接受回环地址请求,并校验本次随机会话令牌。
用户只要求实时显示串口时,到此为止:打开终端面板并持续监测,不附带设备搜索、协议登录、文件上传或升级。
日志和状态读取设置 `2500ms` 超时,包含读取响应体的等待;超时后页面可继续下一次刷新。这不是串口发送请求的统一超时,发送结果未知时不得直接重发命令。
用户只要求实时显示串口时,到此为止:保持同一可见网页只读采集,不附带设备搜索、协议登录、文件上传或升级;不为证明可写而发送空回车或其他探测数据。
## 异常时的本地只读诊断
先检查以下已有证据,不向设备发送登录名、密码、Shell 命令、空回车、break 或 DTR/RTS 脉冲:
- 服务层:检查状态接口是否可达、端口是否匹配且 `isOpen=true`、`lastError`、启动输出和本任务日志末尾的退出原因。接口不可达不能单独证明 COM 口故障;先区分服务未启动、已退出、监听端口冲突和启动命令错误。依据明确错误定位,不靠提升权限或重复执行碰运气。
- 页面层:状态可达而页面异常时,检查 `activeWebClients`、`liveWebClients` 与实际服务版本;不要仅凭轮询超时声称用户关闭了页面。新版页面支持持续连接时应登记对应连接;旧模板或旧令牌需要刷新现有标签,不能通过后台伪造页面保活来补救。助手无法直接操作页面时,仅请用户完成必要的一次刷新并说明原因,不要求其重新执行设备命令。
- 数据层:比较两个时点的 `receivedBytes`、`lastReceiveUtc`、文本和原始字节日志大小。设备静默且无错误时报告“端口已打开,尚未收到新数据”,不能断言故障或宣称收发均正常;数据增长而页面不更新时优先定位显示/请求层。
- 端口层:已有错误明确指向端口不存在、拒绝访问、占用或数据乱码且参数可疑时,停止打开/发送重试,向用户给出具体证据,请其核对 COM 口、波特率、校验、数据位、停止位、流控、占用工具及连接。需要重新插拔、关闭用户工具或更换转接器时由用户处理,不能强制结束其他进程。
保持问题发生时的日志,不为排查移动或截断原始证据。主动离页、持续连接断开和轮询超时是不同事件;只有时间与状态证据支持时才归因。恢复前不自动切换 Telnet、重启设备或重置串口硬件。
## U-Boot 与 A/B 分区手工操作
@@ -30,12 +63,36 @@
## Telnet 与交互命令
1. 先让用户选择串口或 Telnet。串口需要本次确认的 COM 口;Telnet 需要设备完整 SN 和型号,然后广播发现并精确核对 IP,不能沿用历史 IP。
1. 新设备调试且连接方式尚未明确时,让用户选择串口或 Telnet;已明确的选择不反复询问。串口需要本任务已确认的 COM 口;Telnet 需要设备完整 SN 和型号,然后广播发现并精确核对 IP,不能沿用历史 IP。
2. Telnet 登录名和密码从当前 Linux 工程的准确配置读取,优先检查 `product.json` 中的用户配置。不要从截图 OCR、相似机型、旧固件或历史记忆猜测,也不要把凭据写入 skill。
3. 当前配置认证失败或设备版本与配置不一致时再调查历史固件配置。限制失败登录次数;首次明确失败后先停手核实,不连续试猜密码。
4. 串口与 Telnet 是否共用密码必须由工程配置或用户确认,不能推断。
5. 用户明确要求 `ifconfig` 等命令时,若已有已验证且仍连接的会话,就只发送该命令并实时展示结果;登录验证只执行最少的只读命令,完成后退出。
## 网卡未启动的分层诊断
不要看到“没有 IP”就直接归因于 Wi-Fi 密码、DHCP 或硬件。按证据层级定位,前一层成立后再进入下一层:
1. **总线与硬件**:用 `lsusb`、sysfs 和 `dmesg` 确认设备枚举、VID/PID、接口和断连记录。USB 已枚举只能证明总线可见,不能证明驱动已绑定。
2. **驱动兼容性**:确认目标 ROM 中存在对应 `.ko`,核对模块名、`vermagic`、依赖和 USB alias。手动 `insmod` 后观察是否创建 `phy`、`rfkill` 和 `wlan0`;加载失败时保留完整内核错误。
3. **链路与射频**:执行 `ifconfig wlan0 up` 或等价命令,并用扫描结果区分“驱动可用但未关联”和“射频/硬件不可用”。能扫描到 AP 是驱动、设备绑定和基本射频链路成立的证据,不等于网络已连通。
4. **开机加载链**:从当前设备的 `appinit.sh`、模块加载脚本和工程打包映射反向追踪权威源文件、生成文件与 ROM 内最终文件。不能只修改生成目录;重新打包后要从 ROM 提取或挂载验证最终 `loadko` 内容、加载顺序和驱动哈希。
5. **关联与地址**:分别检查 wpa_supplicant、关联状态、DHCP 客户端、IPv4、默认路由和 DNS。不要把 DHCP 失败与驱动未加载混为一个故障。
6. **应用服务**:确认依赖网络设备的服务是否在网卡出现后自动启动或重试,并检查设备端 `8942/8943` 等任务相关端口是否监听。端口监听与 PC 可达是两项不同证据。
手动加载驱动、创建 `wlan0`、关联 AP 或取得临时 IP,只能作为诊断证据或为受控升级创造条件,不能判定开机问题已经修复。最终验收必须使用包含修复的新固件重启,并证明驱动、网卡、关联、DHCP、路由和目标服务无需人工干预即可恢复。
设备端已有 IP 且目标端口正在监听,但 Windows 无法建立 ARP 或 TCP 时,继续检查 PC 侧:
- 是否有多个物理或虚拟接口占用同一网段,实际选路是否使用预期源地址;
- 相关网卡是否真实连接,旧的 DHCP 地址是否已进入 `Deprecated` 状态;
- WLAN 软件无线电是否关闭、目标配置文件是否存在、AP 是否隔离有线与无线客户端;
- 必要时显式绑定源地址做只读连接测试,以区分错误选路和设备拒绝;不要未经授权修改默认路由或提升权限。
如果打开 WLAN、添加路由或改变接口状态需要管理员权限或用户交互,停止自动操作并请用户完成最小动作。不要通过不受控的键盘注入或高风险系统修改绕过权限。
产品配置 MAC、协议返回 MAC 与无线模块原始 MAC 可能不同。恢复网络前先比较三者;只有当前产品配置和设备身份均已确认,且网络准入、DHCP 租约或设备服务确实依赖产品 MAC 时,才在关联和 DHCP 之前临时恢复产品 MAC。不得把任何实测 MAC、SSID 或 PSK 写入 skill。
## 成功判据
完整成功需要组合证据:
@@ -67,6 +124,8 @@
日志中的 `error`、`failed`、`timeout` 字样不是自动失败判据。结合发生时间、是否重复、协议终态、设备功能、重启次数以及是否出现 panic、segfault、core dump 或看门狗循环判断。升级停止和启动阶段的一次性模块告警可以记录为警告;若同一 SN 已正常重启、网络恢复、版本正确并持续心跳,不应仅凭孤立字样推翻成功结论。
本地网卡、DHCP、路由、DNS、协议端口和 `30101` 都正常时,可以把“本地网络与升级闭环”判为通过。云平台激活、Token、绑定、网关发现或业务鉴权错误要单列为云业务警告或失败项;不能用它们推翻已取得的本地网络证据,也不能把本地网络通过扩张为整机云业务全部通过。
## 日志快照与远端协查
持续监测期间不要为了上传而关闭串口。复制本任务生成的活动文本日志和原始字节文件形成稳定快照,记录时间范围并计算 SHA-256,再上传到约定的远端临时目录,例如 `/tmp/<项目>-windows-logs/<时间戳>`。
@@ -89,12 +148,20 @@
更新串口服务或页面后至少完成以下检查:
此处指服务或页面实现变更;仅修改文档时校验技能结构、相对链接和规则一致性,不启动串口生命周期测试,不关闭或重启用户正在使用的控制台。实际设备发送、关闭最后页面等会影响调试的验收,需在用户授权范围内安排;未执行则列为待验收。
1. 使用 `serial_web_console.ps1 -ValidateOnly` 验证 C# 服务能够编译且网页资源存在,再运行 Skill 的 `quick_validate.py`。
2. 启动前确认目标 COM 口没有被第二个串口工具占用;启动后核对状态接口报告端口已打开,不能只根据 PowerShell 进程存在作结论。
3. 设备有输出时核对接收字节数和文本日志文件大小确实增长;设备静默时明确报告“尚未收到数据”,不能把空白页面说成实时采集正常。
4. 发送普通文本后,核对设备响应及对应 `[TX:命令]`;命令框为空时发送 Enter,日志必须出现 `[TX:命令] <CR>`。
5. 首次打开或刷新页面时应显示最新日志;勾选自动滚动后立即回到底部并持续跟随,用户向上查看历史时页面不能强制抢回滚动位置。
5. 首次打开或刷新页面时应显示最新日志;无日志选区时勾选自动滚动立即回到底部并持续跟随,用户向上查看历史时页面不能强制抢回滚动位置。有选区时以选区保护优先。
6. 核对 Enter 发送、Shift+Enter 换行、上下键隐式历史、Esc 清空、输入法候选确认不误发、长按 Enter 不连续空发,以及请求进行中不重复提交。
7. 出现 `Password:` 后输入框应自动遮挡,密码不加入页面历史;按当前调试策略,TX 文本日志仍保存实际发送内容。
8. 打开两个页面时关闭其中一个,服务应继续运行;关闭最后一个页面后,正常离页约 `2` 秒、未触发离页事件时最迟约 `5` 秒,状态接口应离线、宿主进程应退出,日志应写入结束标记且 COM 口应释放。刷新页面不能误停服务。
9. 自动停止后核对文本日志和原始字节文件仍位于原路径且未被截断;再次启动服务并确认能够重新打开同一 COM 口。升级期间关闭最后页面也按相同规则停止,不能宣称仍在后台监测。
10. 单独测试页面持续连接:不发送轮询请求至少 `10` 秒,串口仍应连接并持续接收;断开连接但不发送离页通知,仍应在约 `5` 秒内退出。同一客户端新连接接管后关闭旧连接不能误删新连接。请求缺少会话令牌时不能登记持续连接。自动化协议测试不能代替真实页面的后台/刷新可见性验收,无法实测的项需明确报告。
11. 在真实浏览器拖选或双击选中日志,跨越多轮刷新后确认选区和滚动位置保持;按 `Ctrl+C` 并核对复制内容与选区一致。同时确认后台接收字节数、文本及原始字节文件仍增长;取消选择后页面恢复更新。替身单测覆盖无变化不重建节点、拖选尚无选区、响应/布局帧竞态、错误分支保护及恢复绘制,但不能替代真实选择和剪贴板验收。此项不需要发送任何设备命令。
验收记录分别列出代码/编译检查、DOM/网络替身单测、真实串口 HTTP 生命周期测试和实际浏览器交互。某一层通过不代表其余层通过,不把持续连接数当作窗口可见性证明,也不把未发设备数据的生命周期测试当作普通命令与空回车 TX 验收。
维护时可运行 `node scripts/test_serial_console_page.cjs` 检查页面逻辑(DOM/网络替身,不是真实浏览器验收)。`python scripts/test_serial_web_console.py --execute --port <本次确认的COM口> --log-dir <新的测试日志目录>` 会在独立本机端口真实打开串口并测试生命周期,默认不发送任何设备数据;先停止本技能原控制台,其他工具占用时按串口准备规则停手,不能强制关闭。该测试会多次正常退出并重新打开自己的控制台,结束后释放串口,不维持生产页面保活。生产服务重新启动后需刷新现有页面加载新模板及新会话令牌。
+95 -6
View File
@@ -29,6 +29,7 @@ namespace EmbeddedAppPcDebug
private readonly object _logLock = new object();
private readonly object _stateLock = new object();
private readonly Dictionary<string, DateTime> _webClients = new Dictionary<string, DateTime>(StringComparer.Ordinal);
private readonly Dictionary<string, HttpListenerResponse> _liveWebClients = new Dictionary<string, HttpListenerResponse>(StringComparer.Ordinal);
private HttpListener _listener;
private FileStream _rawStream;
@@ -122,6 +123,8 @@ namespace EmbeddedAppPcDebug
_rawPath));
_listener = new HttpListener();
// 必须收到页面连接断开的写异常,不能吞掉后把已关闭的页面永久计为在线。
_listener.IgnoreWriteExceptions = false;
_listener.Prefixes.Add(_listenPrefix);
_listener.Start();
@@ -269,6 +272,7 @@ namespace EmbeddedAppPcDebug
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128) return;
lock (_stateLock)
{
if (_stopping) return;
_webClients[clientId] = DateTime.UtcNow;
_hadWebClient = true;
_noClientSinceUtc = DateTime.MinValue;
@@ -276,14 +280,76 @@ namespace EmbeddedAppPcDebug
}
private void RemoveWebClient(string clientId)
{
RemoveWebClient(clientId, null, "网页主动离页");
}
private void RemoveWebClient(string clientId, HttpListenerResponse expectedResponse, string reason)
{
if (string.IsNullOrWhiteSpace(clientId)) return;
HttpListenerResponse liveResponse;
bool removed;
lock (_stateLock)
{
_webClients.Remove(clientId);
_liveWebClients.TryGetValue(clientId, out liveResponse);
// EventSource 重连可能先建立新连接,旧连接的 finally 不能删除新连接。
if (expectedResponse != null && !object.ReferenceEquals(liveResponse, expectedResponse)) return;
_liveWebClients.Remove(clientId);
removed = _webClients.Remove(clientId);
if (_hadWebClient && _webClients.Count == 0 && _noClientSinceUtc == DateTime.MinValue)
_noClientSinceUtc = DateTime.UtcNow;
}
if (liveResponse != null) { try { liveResponse.Abort(); } catch { } }
if (removed && !_stopping) WriteSystemLog(reason + ";客户端 " + clientId);
}
private void SendClientLiveness(HttpListenerContext context)
{
string clientId = context.Request.QueryString["client"];
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128)
throw new ArgumentException("页面客户端标识无效。");
HttpListenerResponse response = context.Response;
HttpListenerResponse previousResponse;
lock (_stateLock)
{
if (_stopping) throw new IOException("串口网页服务正在结束。");
_liveWebClients.TryGetValue(clientId, out previousResponse);
_liveWebClients[clientId] = response;
_webClients[clientId] = DateTime.UtcNow;
_hadWebClient = true;
_noClientSinceUtc = DateTime.MinValue;
}
if (previousResponse != null) { try { previousResponse.Abort(); } catch { } }
try
{
response.StatusCode = 200;
response.ContentType = "text/event-stream; charset=utf-8";
response.Headers["Cache-Control"] = "no-store";
response.SendChunked = true;
byte[] heartbeat = Encoding.UTF8.GetBytes("retry: 500\n: page connection alive\n\n");
// 原生 EventSource 持续连接不依赖页面 setInterval。即使页面定时器被节流,
// 仍由服务端每秒探测一次连接;页面关闭/崩溃时,写失败后释放该页面租约。
while (!_stopping)
{
lock (_stateLock)
{
HttpListenerResponse current;
if (!_liveWebClients.TryGetValue(clientId, out current) || !object.ReferenceEquals(current, response)) break;
}
response.OutputStream.Write(heartbeat, 0, heartbeat.Length);
response.OutputStream.Flush();
Thread.Sleep(1000);
}
}
catch (HttpListenerException) { /* 断开的网页连接是正常生命周期事件,不是串口故障。 */ }
catch (IOException) { }
catch (ObjectDisposedException) { }
finally
{
RemoveWebClient(clientId, response, "网页持续连接已断开(未收到主动离页通知)");
try { response.Abort(); } catch { }
}
}
private void WebClientMonitorLoop()
@@ -297,10 +363,14 @@ namespace EmbeddedAppPcDebug
List<string> expiredClients = new List<string>();
foreach (KeyValuePair<string, DateTime> item in _webClients)
{
if ((now - item.Value).TotalSeconds >= WebClientTimeoutSeconds)
if (!_liveWebClients.ContainsKey(item.Key) && (now - item.Value).TotalSeconds >= WebClientTimeoutSeconds)
expiredClients.Add(item.Key);
}
foreach (string clientId in expiredClients) _webClients.Remove(clientId);
foreach (string clientId in expiredClients)
{
_webClients.Remove(clientId);
WriteSystemLog("网页轮询心跳超时(不能据此断定页面已关闭);客户端 " + clientId);
}
if (_webClients.Count > 0)
{
@@ -310,13 +380,14 @@ namespace EmbeddedAppPcDebug
{
if (_noClientSinceUtc == DateTime.MinValue) _noClientSinceUtc = now;
shouldStop = (now - _noClientSinceUtc).TotalMilliseconds >= LastClientGraceMilliseconds;
// 判定和置位必须在同一把锁内,防止刷新页面刚接管就被旧判定停止。
if (shouldStop) _stopping = true;
}
}
if (shouldStop)
{
WriteSystemLog("最后一个串口网页已关闭,正在停止日志采集并释放串口。");
_stopping = true;
WriteSystemLog("所有串口网页连接已结束,宽限期内无页面接管;正在停止日志采集并释放串口。");
try { if (_listener != null) _listener.Stop(); } catch { }
break;
}
@@ -377,6 +448,13 @@ namespace EmbeddedAppPcDebug
return;
}
if (method == "GET" && path == "/api/client/live")
{
ValidateDisconnectRequest(context);
SendClientLiveness(context);
return;
}
if (method == "GET" && path == "/api/log")
{
TouchWebClient(context.Request.QueryString["client"]);
@@ -442,7 +520,7 @@ namespace EmbeddedAppPcDebug
private void ValidateDisconnectRequest(HttpListenerContext context)
{
// sendBeacon 无法自定义请求头,因此仅离页通知接口允许在查询参数中携带会话令牌。
// sendBeacon / EventSource 无法自定义请求头,仅页面生命周期接口允许查询令牌。
string token = context.Request.Headers["X-Serial-Token"];
if (string.IsNullOrWhiteSpace(token)) token = context.Request.QueryString["token"];
if (!string.Equals(token, _sessionToken, StringComparison.Ordinal))
@@ -503,11 +581,13 @@ namespace EmbeddedAppPcDebug
string lastError;
DateTime lastReceive;
int activeWebClients;
int liveWebClients;
lock (_stateLock)
{
lastError = _lastError;
lastReceive = _lastReceiveUtc;
activeWebClients = _webClients.Count;
liveWebClients = _liveWebClients.Count;
}
return "{" +
@@ -516,6 +596,7 @@ namespace EmbeddedAppPcDebug
"\"receivedBytes\":" + Interlocked.Read(ref _receivedBytes).ToString(CultureInfo.InvariantCulture) + "," +
"\"lastReceiveUtc\":\"" + (lastReceive == DateTime.MinValue ? string.Empty : lastReceive.ToString("o", CultureInfo.InvariantCulture)) + "\"," +
"\"activeWebClients\":" + activeWebClients.ToString(CultureInfo.InvariantCulture) + "," +
"\"liveWebClients\":" + liveWebClients.ToString(CultureInfo.InvariantCulture) + "," +
"\"lastError\":\"" + JsonEscape(lastError) + "\"" +
"}";
}
@@ -581,6 +662,14 @@ namespace EmbeddedAppPcDebug
public void Stop()
{
_stopping = true;
List<HttpListenerResponse> liveResponses;
lock (_stateLock)
{
liveResponses = new List<HttpListenerResponse>(_liveWebClients.Values);
_liveWebClients.Clear();
_webClients.Clear();
}
foreach (HttpListenerResponse response in liveResponses) { try { response.Abort(); } catch { } }
try { if (_listener != null) _listener.Stop(); } catch { }
try { if (_serialPort.IsOpen) _serialPort.Close(); } catch { }
+4 -45
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""V1/V2 身份预检与 EPFP/4001 固件升级工具。"""
"""设备 V2 基础信息预检与 EPFP/4001 固件升级工具。"""
from __future__ import annotations
@@ -21,8 +21,6 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
INFO_PORT = 8942
UPGRADE_PORT = 8943
INFO_COMMAND = 30101
V1_INFO_PORT = 4941
V1_INFO_COMMAND = 0x5A0E2104
UPGRADE_COMMAND = 4001
MAX_JSON_SIZE = 1024 * 1024
MAX_STATUS_SIZE = 1024 * 1024
@@ -118,30 +116,6 @@ def query_device_info(host: str, timeout: float, logger: EventLogger) -> Dict[st
return response
def query_v1_device_info(host: str, timeout: float, logger: EventLogger) -> Dict[str, Any]:
"""通过 V1 工厂 TCP/2104 获取设备身份与固件字段。"""
logger.emit("v1_info_request", host=host, port=V1_INFO_PORT,
command="0x%08X" % V1_INFO_COMMAND)
with socket.create_connection((host, V1_INFO_PORT), timeout=timeout) as sock:
sock.settimeout(timeout)
sock.sendall(struct.pack("!II", V1_INFO_COMMAND, 0))
header = recv_exact(sock, 8)
command, size = struct.unpack("!II", header)
if command != V1_INFO_COMMAND or size > MAX_JSON_SIZE:
raise UpgradeError("V1 固件信息响应 CMD 或长度异常")
payload = recv_exact(sock, size)
try:
response = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise UpgradeError("V1 固件信息响应不是有效 JSON:%s" % exc) from exc
if not isinstance(response, dict) or response.get("code") != 0:
raise UpgradeError("V1 固件信息查询失败或响应格式异常")
if not response.get("sn") or not response.get("dev_type"):
raise UpgradeError("V1 固件信息缺少 SN 或 dev_type,拒绝升级")
logger.emit("device_info", protocol="V1", response=response)
return response
def response_data(response: Dict[str, Any]) -> Dict[str, Any]:
"""取得响应 data 对象,格式错误时拒绝继续。"""
@@ -405,11 +379,9 @@ def build_parser() -> argparse.ArgumentParser:
"""创建命令行参数解析器。"""
parser = argparse.ArgumentParser(
description="设备身份预检与 EPFP/4001 ROM 升级;默认仅预检。"
description="V2 设备 30101 身份预检与 EPFP/4001 ROM 升级;默认仅预检。"
)
parser.add_argument("--host", required=True, help="目标设备 IPv4/主机名")
parser.add_argument("--protocol", choices=("v1", "v2"), default="v2",
help="身份查询协议;固件传输均使用 EPFP/4001")
parser.add_argument("--expected-sn", required=True, help="必须精确匹配的完整设备 SN")
parser.add_argument("--expected-model", help="可选,必须出现在设备型号字段中的型号")
parser.add_argument("--rom", required=True, type=Path, help="ROM 文件绝对或相对路径")
@@ -450,23 +422,10 @@ def main(argv: Optional[List[str]] = None) -> int:
logger.emit("mode", execute=bool(args.execute), log_file=str(logger.path))
rom = validate_rom(args.rom)
logger.emit("rom", **rom)
if args.protocol == "v1":
response = query_v1_device_info(args.host, args.connect_timeout, logger)
if response.get("sn") != args.expected_sn:
raise UpgradeError("设备 SN 不匹配:期望 %s,实际 %r" % (args.expected_sn, response.get("sn")))
if args.expected_model and args.expected_model not in str(response.get("dev_type", "")):
raise UpgradeError("设备型号不匹配:期望 %s,实际 %r" % (args.expected_model, response.get("dev_type")))
data = {"sn": response.get("sn"), "inner": {"type": response.get("dev_type"),
"version": response.get("firmware")}, "outer": {"version": response.get("dev_ver")},
"hardware": {"version": response.get("hardware")}}
models = [str(response["dev_type"])]
else:
response = query_device_info(args.host, args.connect_timeout, logger)
data, models = validate_identity(response, args.expected_sn, args.expected_model)
response = query_device_info(args.host, args.connect_timeout, logger)
data, models = validate_identity(response, args.expected_sn, args.expected_model)
summary = {
"host": args.host,
"identity_protocol": args.protocol,
"upgrade_protocol_compatibility": "V1 身份查询 + EPFP/4001;仍须依据目标固件资料确认兼容性",
"sn": data.get("sn"),
"mac": data.get("mac"),
"models": models,
+5 -10
View File
@@ -193,15 +193,14 @@ class SerialWebSession(DeviceSession):
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))
)
# 只有用户的网页维护页面租约。自动化读状态/日志不能冒充网页保活,
# 否则用户关闭最后页面后,本工具轮询会阻止串口按约定释放。
status = json.loads(self._request("/api/status"))
if not status.get("isOpen"):
raise DebugError("串口网页控制台存在,但串口没有打开")
actual_port = str(status.get("port") or "")
@@ -234,9 +233,7 @@ class SerialWebSession(DeviceSession):
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")
start_log = self._request("/api/log").decode("utf-8", errors="replace")
self._request(
"/api/send?ending=CR",
data=wrapped.encode("utf-8"),
@@ -247,9 +244,7 @@ class SerialWebSession(DeviceSession):
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")
latest = self._request("/api/log").decode("utf-8", errors="replace")
matches = pattern.findall(latest)
if matches:
offset = min(len(start_log), len(latest))
+208
View File
@@ -0,0 +1,208 @@
// 无浏览器依赖的页面逻辑回归;使用 DOM/网络替身,不操作用户界面或真实串口。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
async function main() {
const elements = new Map();
function element(id) {
if (!elements.has(id)) {
const classes = new Set();
elements.set(id, {
value: '', textContent: '', checked: true, style: {}, dataset: {},
scrollTop: 0, scrollHeight: 1000, clientHeight: 200,
selectionStart: 0, selectionEnd: 0, handlers: {},
classList: {
toggle(name, active) { if (active) classes.add(name); else classes.delete(name); },
remove(name) { classes.delete(name); },
contains(name) { return classes.has(name); }
},
addEventListener(name, callback) { this.handlers[name] = callback; },
focus() {},
setSelectionRange(start, end) { this.selectionStart = start; this.selectionEnd = end; }
});
}
return elements.get(id);
}
const windows = {};
const sources = [];
const calls = [];
const timeouts = new Map();
let nextTimer = 1;
let log = '[2026-10-21 16:00:00] localhost login:';
let hangStatusBody = false;
let selectedLog = false;
let selectedElsewhere = false;
let failLog = false;
let onLogBody = null;
let deferFrame = false;
const frames = [];
const bytes = text => new TextEncoder().encode(text).buffer;
const sandbox = {
console, TextDecoder, TextEncoder, AbortController,
document: {
body: { dataset: { token: 'test-token', port: 'COM-TEST' } },
querySelector: element, querySelectorAll: () => []
},
window: {
crypto: { randomUUID: () => 'test-page' },
getSelection: () => ({
isCollapsed: !selectedLog && !selectedElsewhere, rangeCount: 1,
getRangeAt: () => ({ intersectsNode: node => selectedLog && node === element('#log') })
}),
addEventListener(name, callback) { windows[name] = callback; }
},
navigator: { sendBeacon: () => true },
EventSource: class {
constructor(url) { this.url = url; this.closed = false; sources.push(this); }
close() { this.closed = true; }
},
requestAnimationFrame(callback) { if (deferFrame) frames.push(callback); else callback(); },
setInterval() {},
setTimeout(callback) { const id = nextTimer++; timeouts.set(id, callback); return id; },
clearTimeout(id) { timeouts.delete(id); },
async fetch(url, options = {}) {
calls.push({ url, options });
if (options.method === 'POST') return { ok: true, json: async () => ({ status: 'written' }) };
if (url.startsWith('/api/status')) {
return {
ok: true,
arrayBuffer: () => hangStatusBody
? new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted'))))
: Promise.resolve(bytes(JSON.stringify({ isOpen: true, port: 'COM-TEST', receivedBytes: 10 })))
};
}
if (failLog) throw new Error('test log failure');
return { ok: true, arrayBuffer: async () => { if (onLogBody) onLogBody(); return bytes(log); } };
}
};
const context = vm.createContext(sandbox);
const html = fs.readFileSync(path.join(__dirname, '../assets/serial-console.html'), 'utf8');
const source = html.match(/<script>([\s\S]*?)<\/script>/)[1];
vm.runInContext(source, context);
const run = text => vm.runInContext(text, context);
const flush = () => new Promise(resolve => setImmediate(resolve));
await flush();
assert.equal(sources.length, 1);
assert.match(sources[0].url, /client=test-page&token=test-token/);
assert.equal(element('#log').scrollTop, 1000, '首次显示最新日志');
element('#log').scrollTop = 0;
log += '\nnew line';
await run('refreshLog()');
assert.equal(element('#log').scrollTop, 0, '用户向上查看时不抢滚动位置');
element('#follow').handlers.change();
assert.equal(element('#log').scrollTop, 1000);
// 即使没有新增日志,也不能无条件替换文本节点。
let displayed = element('#log').textContent;
let replacements = 0;
Object.defineProperty(element('#log'), 'textContent', {
get() { return displayed; },
set(value) { displayed = value; replacements++; }
});
await run('refreshLog()');
assert.equal(replacements, 0, '相同正文不重建文本节点');
const heldText = displayed;
selectedLog = true;
log += '\narrived while selected';
await run('refreshLog()');
assert.equal(displayed, heldText, '选中日志时保留文本节点');
assert.equal(run('logPolling'), false, '暂停绘制不阻塞下一次轮询');
element('#log').scrollTop = 20;
element('#follow').handlers.change();
assert.equal(element('#log').scrollTop, 20, '有选区时不能自动滚动');
failLog = true;
await run('refreshLog()');
assert.equal(displayed, heldText, '读取失败也不能破坏选区');
failLog = false;
selectedLog = false;
element('#log').handlers.pointerdown({ button: 0 });
await run('refreshLog()');
assert.equal(displayed, heldText, '拖选尚未形成非空选区时也保护文本');
windows.pointerup();
await run('refreshLog()');
assert.equal(displayed, log, '松开且取消选区后恢复最新日志');
selectedElsewhere = true;
log += '\nselected outside log';
await run('refreshLog()');
assert.equal(displayed, log, '选择其他区域不能暂停日志');
selectedElsewhere = false;
const beforeRace = displayed;
log += '\nselection during response';
onLogBody = () => { selectedLog = true; };
await run('refreshLog()');
assert.equal(displayed, beforeRace, '响应等待期间形成的选区也受保护');
onLogBody = null;
selectedLog = false;
element('#log').scrollTop = 1000;
deferFrame = true;
await run('refreshLog()');
assert.ok(frames.length > 0);
selectedLog = true;
element('#log').scrollTop = 30;
for (const callback of frames.splice(0)) callback();
assert.equal(element('#log').scrollTop, 30, '布局帧等待期间开始选择也不能抢滚动');
selectedLog = false;
deferFrame = false;
element('#log').handlers.pointerdown({ button: 0 });
windows.blur();
assert.equal(run('isLogSelectionActive()'), false, '窗口失焦不遗留拖选锁');
// 正文读取也必须在超时保护范围内,而不只是拿到响应头。
hangStatusBody = true;
const hung = run('refreshStatus()');
await flush();
for (const callback of [...timeouts.values()]) callback();
await hung;
assert.equal(run('statusPolling'), false);
hangStatusBody = false;
await run('refreshStatus()');
assert.equal(run('serialOpen'), true, '超时后能再次请求并恢复连接状态');
log = 'Password:';
await run('refreshLog()');
assert.equal(element('#command').classList.contains('masked'), true);
element('#command').value = 'fake-test-secret';
await run('sendCommand()');
assert.equal(run('commandHistory.length'), 0, '密码不进入页面历史');
log = 'localhost login:';
await run('refreshLog()');
element('#command').value = '';
const commandCalls = () => calls.filter(call => call.url === '/api/send?ending=CR');
const before = commandCalls().length;
element('#command').handlers.keydown({ key: 'Enter', isComposing: true });
assert.equal(commandCalls().length, before, '输入法候选确认不能发送');
element('#command').handlers.keydown({ key: 'Enter', repeat: true, preventDefault() {} });
assert.equal(commandCalls().length, before, '长按 Enter 不能重复空发');
await run('sendCommand()');
assert.equal(commandCalls().at(-1).options.body, '', '空输入按 CR 接口发送');
element('#command').value = 'test-command';
await run('sendCommand()');
assert.equal(commandCalls().at(-1).options.body, 'test-command');
assert.equal(element('#command').value, '');
element('#command').handlers.keydown({ key: 'ArrowUp', preventDefault() {} });
assert.equal(element('#command').value, 'test-command');
element('#command').handlers.keydown({ key: 'Escape', preventDefault() {} });
assert.equal(element('#command').value, '');
windows.pagehide();
assert.equal(sources[0].closed, true);
const count = calls.length;
await run('refreshStatus(); refreshLog()');
assert.equal(calls.length, count, '离页后不能继续轮询把已关闭页面重新登记');
windows.pageshow();
await flush();
assert.equal(sources.length, 2, '从页面缓存恢复时重建当前页面连接');
assert.equal(sources[1].closed, false);
windows.pageshow();
assert.equal(sources.length, 2, '重复 pageshow 不得创建重复连接');
console.log(JSON.stringify({ passed: true, checks: [
'liveness lifecycle', 'poll timeout recovery', 'initial/follow scroll',
'password history exclusion', 'IME/Enter guard', 'empty CR request',
'command request/history', 'pagehide/pageshow', 'unchanged log DOM retention',
'selection/drag protection', 'selection during response/frame', 'selection resume'
] }));
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""显式使用已确认的串口,回归控制台 HTTP 生命周期;不发送设备命令。
示例:python test_serial_web_console.py --execute --port COM62 --log-dir <目录>
测试独立的本机端口,测试结束释放串口,不创建常驻进程或生产页面保活。
"""
import argparse
import http.client
import json
import re
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
class LivePage:
"""模拟真正的页面 SSE 连接,持续消费响应;close 模拟页面销毁。"""
def __init__(self, port, client, token):
self.connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
query = urllib.parse.urlencode({"client": client, "token": token})
self.connection.request("GET", "/api/client/live?" + query)
self.response = self.connection.getresponse()
assert self.response.status == 200, self.response.status
self.running = True
self.frames = 0
self.worker = threading.Thread(target=self.consume, daemon=True)
self.worker.start()
def consume(self):
try:
while self.running:
if not self.response.readline():
break
self.frames += 1
except (OSError, ValueError, http.client.HTTPException):
pass
def close(self):
self.running = False
# 主动关闭 socket,不依赖 HTTPResponse.close 等待另一线程的阻塞读取结束。
if self.connection.sock:
import socket
try:
self.connection.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self.connection.close()
self.worker.join(2)
self.response.close()
class ConsoleTest:
def __init__(self, args):
self.args = args
self.base = "http://127.0.0.1:%d" % args.web_port
self.process = None
self.output = None
self.pages = []
self.results = []
def get(self, path):
with urllib.request.urlopen(self.base + path, timeout=2) as response:
return response.read().decode("utf-8")
def status(self):
return json.loads(self.get("/api/status"))
def record(self, name, **details):
record = {"test": name, "passed": True, **details}
self.results.append(record)
print(json.dumps(record, ensure_ascii=False), flush=True)
def start(self, label):
self.log = self.args.log_dir / (label + ".log")
assert not self.log.exists(), "不能覆盖既有测试日志"
self.output = (self.args.log_dir / (label + ".host.log")).open("wb")
script = Path(__file__).with_name("serial_web_console.ps1")
self.process = subprocess.Popen(
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "RemoteSigned",
"-File", str(script), "-Port", self.args.port, "-LogPath", str(self.log),
"-WebPort", str(self.args.web_port)],
stdout=self.output, stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NO_WINDOW,
)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
assert self.process.poll() is None, "控制台启动失败,查看 host.log"
try:
status = self.status()
assert status["isOpen"] and status["port"] == self.args.port
self.token = re.search(r'data-token="([^"]+)"', self.get("/")).group(1)
return
except urllib.error.URLError:
time.sleep(.1)
raise AssertionError("控制台启动超时")
def page(self, client):
page = LivePage(self.args.web_port, client, self.token)
self.pages.append(page)
return page
def disconnect(self, client):
query = urllib.parse.urlencode({"client": client})
request = urllib.request.Request(
self.base + "/api/client/disconnect?" + query, data=b"",
headers={"X-Serial-Token": self.token}, method="POST",
)
with urllib.request.urlopen(request, timeout=2) as response:
assert response.status == 200
def wait_exit(self, name, since):
self.process.wait(timeout=5)
elapsed = time.monotonic() - since
assert elapsed < 5 and self.process.returncode == 0, elapsed
try:
self.status()
raise AssertionError("进程结束后接口仍可访问")
except urllib.error.URLError:
pass
text = self.log.read_text(encoding="utf-8")
assert "串口网页服务结束" in text
assert "[TX:" not in text, "测试不应发送任何设备数据"
raw = Path(str(self.log) + ".raw.bin")
assert raw.exists()
self.output.close()
self.output = None
self.record(name, exit_seconds=round(elapsed, 3), raw_bytes=raw.stat().st_size)
def run(self):
self.start("lifecycle")
time.sleep(5.2)
assert self.status()["isOpen"], "从未打开页面时不能自行退出"
self.record("no_page_yet")
try:
self.get("/api/client/live?client=unauthorized")
raise AssertionError("缺少令牌仍建立连接")
except urllib.error.HTTPError as error:
assert error.code == 403
assert self.status()["activeWebClients"] == 0
self.record("liveness_requires_token")
first = self.page("page-first")
second = self.page("page-second")
before = self.status()["receivedBytes"]
# 远超原有 3+2 秒时限;只保持页面连接,不发送任何轮询心跳。
time.sleep(11)
status = self.status()
assert status["isOpen"] and status["liveWebClients"] == 2
assert first.frames > 10 and second.frames > 10
assert status["receivedBytes"] > before, "现场 RX 没有增长,不能宣称采集正常"
self.record("paused_polling_stays_alive", received_bytes=status["receivedBytes"])
first.close()
time.sleep(5.2)
assert self.status()["liveWebClients"] == 1
self.record("closing_one_of_two")
second.close()
replacement = self.page("page-refreshed")
time.sleep(3.2)
assert self.status()["isOpen"] and self.status()["liveWebClients"] == 1
self.record("refresh_handoff")
reconnect = self.page("page-refreshed")
replacement.close()
time.sleep(3.2)
assert self.status()["liveWebClients"] == 1
assert reconnect.frames > 3
self.record("old_connection_cannot_remove_replacement")
start = time.monotonic()
self.disconnect("page-refreshed")
reconnect.close()
self.wait_exit("explicit_last_page_close", start)
# 再次打开同一 COM,证明上一次已释放句柄;断开最后连接但不发离页通知。
self.start("abrupt-disconnect")
page = self.page("page-abrupt")
time.sleep(.3)
start = time.monotonic()
page.close()
self.wait_exit("abrupt_last_page_close", start)
self.start("polling-fallback")
self.get("/api/status?client=legacy-polling")
start = time.monotonic()
# 兼容路径总时限约 5 秒(监测线程 250ms 调度允许少量误差)。
self.process.wait(timeout=6)
elapsed = time.monotonic() - start
assert elapsed < 5.8 and self.process.returncode == 0, elapsed
text = self.log.read_text(encoding="utf-8")
assert "网页轮询心跳超时" in text and "串口网页服务结束" in text
self.record("legacy_polling_fallback", exit_seconds=round(elapsed, 3))
def cleanup(self):
for page in self.pages:
try:
page.close()
except (OSError, ValueError):
pass
if self.process and self.process.poll() is None:
self.process.terminate() # 仅终止本测试创建的控制台,不触碰其他串口工具。
self.process.wait(timeout=5)
if self.output:
self.output.close()
(self.args.log_dir / "results.json").write_text(
json.dumps(self.results, ensure_ascii=False, indent=2), encoding="utf-8"
)
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--execute", action="store_true")
parser.add_argument("--port", required=True, help="本次用户已确认的 COM 口")
parser.add_argument("--web-port", type=int, default=18766)
parser.add_argument("--log-dir", required=True, type=Path)
args = parser.parse_args()
if not args.execute:
parser.error("实际打开串口必须显式指定 --execute")
args.log_dir.mkdir(parents=True, exist_ok=True)
# 端口被其他服务占用时停止,不能把别人的响应误当成本次测试服务。
import socket
with socket.socket() as probe:
assert probe.connect_ex(("127.0.0.1", args.web_port)) != 0, "测试 HTTP 端口已占用"
test = ConsoleTest(args)
try:
test.run()
finally:
test.cleanup()
if __name__ == "__main__":
main()