Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot committed 2026-10-23 00:02:41 +08:00
1 parent 949a960835
commit ed52dd300b
30 files changed
+1249 -86

No files matched your search

@@ -1,6 +1,6 @@
{
"name": "artemis-automation",
"version": "2.0.0",
"version": "2.0.1",
"description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。",
"author": {
"name": "chenmingxuan",
@@ -1,9 +1,9 @@
{
"sourceId": "artemis-automation",
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
"ref": "v2.0.0",
"commit": "973ccae679b082f9ac5df85b6c2ea272ba29b828",
"ref": "v2.0.1",
"commit": "b584dae80aaae66607d6335bf1efa4b847f1955b",
"adapter": "claude-skill",
"sourcePath": "skills/artemis-use",
"syncedAt": "2026-10-21T06:48:34Z"
"syncedAt": "2026-10-22T16:00:00Z"
}
@@ -1,6 +1,6 @@
{
"name": "embedded-app-pc-debug",
"version": "1.0.0",
"version": "1.0.1",
"description": "Windows 电脑端嵌入式应用与固件调试流程,覆盖构建、发现、串口、部署、升级和诊断。",
"author": {
"name": "zoukai",
@@ -1,9 +1,9 @@
{
"sourceId": "embedded-app-pc-debug",
"repo": "https://git.playones.com/zoukai/embedded-app-pc-debug.git",
"ref": "v1.0.0",
"commit": "cfb586cb5ffc5a69e66cd9adbc57e4ebf725d1d3",
"ref": "v1.0.1",
"commit": "961ccbe8e0bc7473c99f052e5502ae9e89388cbf",
"adapter": "claude-skill",
"sourcePath": ".",
"syncedAt": "2026-09-15T09:16:34Z"
"syncedAt": "2026-10-22T16:00:00Z"
}
@@ -1,6 +1,6 @@
---
name: embedded-app-pc-debug
description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编译、NFS 模块部署、开机进程与加密参数调试,以及设备发现、ROM 升级、串口监测和故障诊断。适用于 Eapil/CamTells 工作流;普通代码开发或仅查看已有日志时不要自动触发。
description: 用于 Windows 电脑端的 Eapil 嵌入式产品与平台调试,包括远程编译、NFS 模块部署、智能编码调试、设备发现、ROM 升级、串口监测和故障诊断;普通代码开发或仅查看已有日志时不要自动触发。
---
# Windows电脑端调试
@@ -13,14 +13,17 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
- 涉及 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)。
- 涉及 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)。
- 涉及晶视智能编码、AVBR 静态场景码率、`vencSmart.json` 调参或压降测试时,读取 [智能编码调试知识](references/smart-encoding-debugging.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 +33,38 @@ 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` 复制。选择期间只暂停日志区域绘制与自动滚动,不中断串口采集、日志保存或页面存活连接;取消选择后恢复刷新。选区保护与真实浏览器验收要求见串口参考文档。
### 串口登录与凭据获取
- 只有用户明确要求登录设备或执行需要登录的诊断时才登录;仅要求查看串口输出时保持只读。
- 需要登录且当前任务可访问对应项目工程时,优先从该工程的 `product.json` 查找登录名和密码配置。先确认项目路径/版本与目标设备型号、固件版本相符;若存在多个可能配置,不能按相似型号或历史记忆猜测。只读取与当前设备匹配的必要字段,并仅在当前会话中使用。
- 若无法访问对应项目仓库,或 `product.json` 中没有可与目标设备和固件版本匹配的配置,则暂停登录并向用户说明缺少的来源或匹配信息;不得尝试默认密码、相似机型密码或从截图/历史聊天复制密码。
- 串口登录必须按当前可见提示逐步进行:只在 `login:` 提示发送用户名;收到 `Password:` 后才发送密码;密码无回显时等待登录结果;只有看到已登录 shell 提示符后才发送诊断命令。不得把用户名、密码和命令连成一串发送,也不得在提示阶段不明时盲发命令。
- 登录失败一次后停止尝试,先核对当前提示阶段、键盘输入字符、设备型号/固件与配置来源。若无法由现有证据解释,不连续试密码,向用户报告并等待新的凭据或指示。
- 不将真实密码、密钥或 Token 写入本 skill、脚本、示例、摘要或交付表格。按当前调试约定,原始串口日志保留发送记录;在复制、上传或转发日志前,先提醒用户检查其中是否含有凭据,并遵循该目的地的授权要求。不得为了分享而覆盖或改写原始证据日志。
## 远程项目持续调试与日志/源码排查
- 对所有产品线和平台工程的问题,默认沿用当前已建立的 SSH PTY 会话和远程工程目录;执行新命令前先检查既有会话是否仍有效。会话有效时直接在原会话操作,不要重复登录或新开终端。持续调试、修改或构建任务尚未完成时,保持该 SSH 会话存活,不主动退出、关闭终端或切换到新会话;如果工具或网络限制导致会话可能超时,优先采用该会话支持的保活方式,并在等待期间避免发送会改变设备状态的命令。若连接因外部原因失效,再按配置主机重新连接,并明确告知会话已重建。
- 用户指定远程工程时,以该工程作为源码修改和构建的权威位置;本地仅保存必要的辅助工具、产物与证据,不另建源码副本作为实际修改入口。确需本地中转补丁时说明用途,应用到远端并核对差异后才能构建,不能把本地修改当作远端已生效。此规则适用于不同产品、芯片平台和项目目录结构,不以 CamTells 目录布局作为前提。
- 远程排查前先确认用户给出的项目路径、分支/工作区和目标文件。先读取 `git status --short` 识别用户或前序任务已有修改;只触碰本次授权范围内的文件,保留其他未提交修改,不自动清理、还原或覆盖。
- 用户要求“分析/排查”时先只读查日志和代码,不顺手修复。日志结论区分三类:日志直接事实、代码明确行为、尚未证实的推断;不要把同一时间出现的事件直接说成因果。
- 先核验日志文件的修改时间、日志内部日期和用户指定时间是否相符。再截取事件前后上下文并过滤高频周期日志,避免截断或周期噪声掩盖真正事件。若证据文件日期/时间段不符,指出缺少对应证据,并只做可由现有源码支持的有限判断。
- 代码改动遵守最小化原则,尊重原实现和调用顺序。先对照相邻代码,沿用类成员函数组织、命名、缩进、括号及格式化风格;已有逻辑按 `类名::函数名` 在类外实现时,不无故将同类职责分散为自由函数。新增代码说明职责、参数来源、关键分支及失败处理,不只逐行翻译语法。改动前后只检查目标文件 diff;不触碰其他现有修改。除非用户要求编译/验证,不擅自引入额外测试或部署;若未编译或设备验证,明确说明验证边界。
- 面向最终报告列出关键时间点、原始日志现象、对应代码路径、判断可信度、仍缺失的证据,以及是否修改/编译/部署。不要把“代码路径可能导致”包装成“日志已证实”。
## Git 与构建边界
@@ -49,6 +73,8 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
- 默认执行 `EmxGitInfo` 并保存输出作为版本凭证;失败时记录原因。临时验证包可继续,正式发布是否继续由用户决定。
- 不使用旧 ROM 冒充本次产物。构建脚本若明确宣告 ROM 目标成功并验证了本次 ROM,之后仅辅助全量镜像步骤失败时,可以交付“仅供局域网升级的 ROM”,但不能声称完整发布包成功。
仅编译指定模块时,使用当前工程已确认的交叉工具链和模块构建目标,不强制走完整 ROM 打包流程,详见 [构建参考文档](references/build-workflow.md)。NFS 联调需保留用户配置,并核对实际运行文件、启动参数和依赖,详见 [应用调试参考文档](references/application-debugging.md);编译成功不能代替设备运行及功能验收。
## 工具脚本
- `scripts/device_upgrade.py`:V2 `30101` 身份预检与 `EPFP/4001` 升级。缺少 `--execute` 时绝不发送 ROM。
@@ -68,3 +94,5 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
不要把连接建立、ROM 发送结束、下载进度达到 100% 或 Flash 计数达到总量单独称为升级成功。至少需要明确的协议成功终态,并继续核对同一 SN 的重启、重发现、网络恢复和目标版本;无法取得某项证据时明确说明,不补造结论。
串口网页控制台发生变更时,还要验证服务代码能编译、Skill 结构有效、COM 口由单一进程占用、服务实际连接、接收字节或日志文件持续增长、普通发送与空回车都有 TX 记录,并确认页面首次打开定位到最新日志。还必须完成生命周期验收:刷新不中断、两个页面关闭一个仍运行、关闭最后页面后约 `5` 秒内进程退出且状态接口离线、日志具有结束标记、原日志与原始字节文件仍在原路径,并且下一次服务能重新打开同一 COM 口。仅看到服务进程或空白网页不算验证通过。
上述串口行为验收适用于服务或页面实现变更;仅修改技能文档时检查结构、引用及规则一致性,不为文档校验打断现有采集。报告区分代码检查、替身单测、真实串口协议测试和实际浏览器操作验收;未执行的项明确标注,不以自动化结果代替完整界面验收。
@@ -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);
@@ -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` 实查。
@@ -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 设备任务直接使用:
@@ -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
@@ -11,6 +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 通用配置接口。
| 功能 | 传输与端口 | 请求/响应格式 |
|---|---|---|
| 局域网搜索 | 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 搜索
源码:`EpSearchPanocsInLan.cpp`、`json/SerachCameraRequest.cpp`、`json/SerachCameraResponse.cpp`。
请求命令 `0x5a0eff00`,响应命令 `0x5a0eff01`。请求 JSON 至少带随机 `session`:
```json
{"session":12345}
```
响应常见字段:`session`、`ip`、`dev_type`、`sn`、`mac`、`firmware`、`hardware`、`dev_id`、`dev_ver`,部分设备另有 `ipv6`、`wifi_ssid`、`wifi_quality`。按回显 session 过滤本轮响应。搜索会列出广播域中的多台设备;不得把第一个响应当目标。升级或其他写操作仍要求完整 SN 与型号核验。若设备 SN 为空,只能将搜索结果用于只读信息查询;SN 空值不构成设备身份核验,不能据此绕过写操作的身份保护。
### V1 通用配置读取命令
以下命令来自 `EapilPanoCHead.h` 的 V1 `MessageCMDType`,对应 `EpPanoCConfigure.cpp` 的 getter/query 实现。大部分请求使用空 payload;按返回命令和 JSON `code` 判定设备是否支持,`code=0` 表示设备成功应答,不代表每个可选字段都存在。
| 项目 | 命令 |
|---|---:|
| 获取 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 协议矩阵
@@ -37,7 +96,7 @@ V2 发现请求 JSON:
- `outer.type`
- `hardware.type`
非关键网络字段允许缺失。完整 SN 是升级身份核对的首要依据。V1 的端口与帧格式必须从 V1 文档或已验证的 `EpProtocolDemo/V1` 获取,不能套用 V2。
非关键网络字段允许缺失。完整 SN 是升级身份核对的首要依据。V1 的端口与帧格式见上文 V1 章节,并以目标设备对应的 V1 文档或源码复核;不能套用 V2。
执行发现时使用 `scripts/v2_discover.py`,并遵守以下规则:
@@ -100,6 +159,8 @@ uint8 status
- 多个 ROM 或设备候选时等待选择。搜索端口被占用或没有完成完整 SN 核对时不得升级。
- 确认后调用 `scripts/device_upgrade.py --execute`,不绕过其保护参数。
串口登录可以确认现场设备、读取版本并恢复网络,但不能替代升级前的 `30101` 身份响应。即使 COM 口已由用户确认且串口内的 SN、型号看似匹配,`30101` 超时、返回不完整或不匹配时仍不得发送 ROM。先检查设备端端口监听、接口 MAC、关联、DHCP、PC 多网卡选路和二层可达性;恢复后重新运行标准预检。
## 已知协议歧义
- 状态章节没有明确说明每次状态是否带 EPFP 外层头,因此客户端兼容完整帧和裸状态。
@@ -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 记录时保留扫描器原始目录分组;注意长字段可能需要确认内容未被截断。
- 预填完成后重新读取表单字段,确认文本内容和换行完整。用户只授权预填时,不点击“提交”或“保存草稿”,并在回复中明确说明记录仍未提交/保存。
@@ -0,0 +1,122 @@
# 晶视智能编码调试知识
## 适用范围与来源
- 厂商依据:《晶视智能编码介绍 V1.7》,修订日期 2024-04-12,适用于 CV181X、SDK V4.1.0。本文页码均指该 PDF 的页码。原始资料由用户提供,位于 `D:/dingding-work/晶视智能编码介绍V1.7.pdf`;本参考可独立阅读,无须依赖该本机路径。
- 工程案例:[会话“排查首次调用初始化回调”中的智能编码讨论](codex://threads/01a1573c-f743-7f70-8005-2e67079bb077?hostId=remote-ssh-discovered%3Aeapil-server),工程为 PT41EC-S/HangYan。这里只采纳其中智能编码内容。
- 厂商能力、旧版平台要求、工程实现和案例调参值各有适用范围。用于其他芯片、SDK 或固件版本前,核实相应接口与实际运行参数;本文未证明任何当前设备已达标。
## 机制与生效条件
这里的 SVC 指 **Smart Video Coding(智能编码)**。其调试分为两个环节:先根据 ISP 直方图统计把静态画面分为简单、中等、复杂场景,再为各类场景配置码率比例。先确认分类正确,再调对应码率。(第 4、8、9 页)
- 该版本 SVC 支持 H.264/H.265,开关用于 AVBR;不能把静态三档调节方法直接套用到 VBR。支持 SBM、FBM。
- 调用 `CVI_VENC_EnableSvc` 使能,再通过 `CVI_VENC_GetSvcParam` / `CVI_VENC_SetSvcParam` 读取、修改 SVC 参数,并检查返回结果。核对匹配的驱动、库、头文件及应用解析,不能仅凭 JSON 中字段存在判断功能已生效。(第 4—7 页)
- 开关方案准备普通编码和智能编码两套 RC 参数。该 SDK 多数 RC 参数不能动态切换,文档要求销毁并重建 VENC 后应用新参数;单独保存配置文件不能证明运行通道已经更新。(第 8 页)
- 该版本支持静态场景压降,不支持动态场景码率压降;H.265 支持动态场景保护,H.264 不支持同样的保护能力。不能因为 H.264 配置中有 `fg_protect_en` 就判定其有效。SVC 会占用部分 TPU 算力,需观察与其他 AI 功能并行的实际效果。(第 4 页)
## 参数怎么选
| 参数 | 调试用途与注意点 |
|---|---|
| `u32MaxBitrate` | 公式中的最大码率。读取活动通道的实际值,不用实测平均码率代替,也不默认沿用示例中的 1500。 |
| `s32ChangePos` | 码率公式中的整体比例,proc 中可显示为 `ChgPs`。先保持实际值不变,用对应场景的比例调节;更改它也会影响动态场景/VBR 的公式上界。 |
| `middle_min_percent` | 静态中等场景比例,范围 `[1,100]`。识别为中等场景时增大它,会提高计算上界,实测通常随之提高,但不保证线性增长。 |
| `complex_min_percent` | 静态复杂场景比例,范围 `[1,100]`。只针对实际识别出的复杂场景;调节方向与中等场景相同。 |
| `MinStillPercent` / `s32MinStillPercent` | 简单静态场景的比例。厂商公式与工程 JSON 的命名可能不同,需核实字段映射。 |
| `complex_scene_detect_en` | 场景复杂度识别开关。 |
| `complex_scene_low_th` / `complex_scene_hight_th` | 分类边界,范围 `[0,512]`。文档推荐 `150/318`;实际阈值依 ISP/PQ 和测试场景调整,低阈值应小于高阈值。保留接口中的 `hight` 拼写。它们不直接指定码率。 |
| QP 限制 | 关注 `u32MinQp`、`u32MaxQp`、`u32MinIQp`、`u32MaxIQp`、`u32MaxStillQP` 及实际 `cur_qp`。预期码率与输出码流不一致时核对这些约束,不先批量修改所有 QP 参数。 |
| `fg_protect_en` / `fg_dealt_qp` | 前景保护及帧级 QP 与运动区域 QP 的差值;后者范围 `[0,8]`,文档建议 `4`。值越大,运动区域保护越明显,静态区域质量可能下降。用途是画质分配,不是静态中等/复杂码率的首选调节项。 |
| `u32RowQpDelta` | 行级码控,范围 `[0,10]`。文档建议 VBR/AVBR 取 `1`,同时说明非零可能影响前景保护,需结合画质观察。 |
| `smart_ai_en` | AI 区域保护还需要有效 AI ROI map 配合;不能只开布尔值。文档建议 `0`,说明该版本此功能暂未开启。 |
上述参数解释来自第 7—9 页及会话案例。文档给出的 `middle_min_percent` / `complex_min_percent` 电信参考值为 `50/80`、移动参考值约为 `15/20`;这些是项目调试起点,不能直接作为所有设备的默认值。运动灵敏度等其他 RC 项先保留项目现值,只有证据指向动静判断异常时再核对其 SDK 定义。
## 码率公式与反算
设 `M = u32MaxBitrate`,`C = s32ChangePos`,`P = 当前静态场景比例`,百分比字段使用 `75`、`60` 这样的数值。
```text
AVBR 静态:平均码率 < M × C/100 × P/100
简单:P = MinStillPercent
中等:P = middle_min_percent
复杂:P = complex_min_percent
AVBR 动态:平均码率 < M × C/100
VBR: 平均码率 < M × C/100(该公式不区分动静场景)
令公式右侧等于期望码率 T,反算参考比例:
P_ref = T × 10000 / (M × C)
```
第 8 页明确使用“小于”。因此这些是平均码率的计算上界,**不是码率下限、精确输出值或达标保证**;参数名中的 `min` 也不能改变该结论。反算只给出调试起点,需留出实验余量再实测。若 `M` 或 `C` 无效,先纠正输入;若 `P_ref > 100`,仅调这两个场景比例无法达到期望,应重新核对最大码率、`s32ChangePos` 与用例要求。`P` 表示码率比例,不等于开启/关闭对比得到的压降率。
### 会话案例:3MP、最大码率 1250 kbps
用户在会话中报告中等/复杂场景约 `148/198 kbps`,要求调向 `500/800 kbps`,并最终明确公式中的最大码率为 **1250 kbps**。这组测量缺少一一对应的活动编码模式、参数快照和统计口径,不能用于验证下表公式。下表只按最终确认的最大码率反算:3MP 配置为 `2304×1296`;H.264 的 `s32ChangePos=75`,H.265 为 `95`。
| 编码 | 原中等/复杂比例 | 反算比例(中等/复杂) | 首轮试调比例(中等/复杂) | 试调值对应公式上界(kbps) |
|---|---|---|---|---|
| H.264 | `12/19` | `53.33%/85.33%` | `60/90` | `562.5/843.75` |
| H.265 | `10/18` | `42.11%/67.37%` | `45/70` | `534.375/831.25` |
该表是会话条件下的算例和待验证起点,不能直接推广到其他最大码率、分辨率、编码格式或固件。先确认活动通道使用包含 `2304×1296` 的配置对象;会话另一个对象只有 `640×360`,不能混用,也不能仅凭数组顺序推定主/子码流映射。原配置高阈值为 `260`,与文档推荐 `318` 不同;若分类已正确,不为提高码率而机械替换阈值。
## 工程开关与运行证据
会话中的 HangYan `source/callback/1.64.x/CallBackResp.cpp`、`source/env/EnvCap.cpp` 使用能力字段 `support_static_scence_ABR`、接口字段 `static_scence_ABR_on`,映射到 `vencParam.smartCode`。该会话所见实现中,手动码率设置会关闭智能编码;开启智能编码会执行视频参数 `Reset()` / `Load()` 并重新赋值 RC 模式、帧率、GOP、码率等。测试前核对目标版本实现,并在每次切换后回读最终参数,避免配置被重置却仍用旧值计算。文档中的“可通过下发高码率提升画质”不代表该工程接口已具有相同语义。
`vencSmart.json` 只是配置入口。依次追踪它的实际加载路径、码流/分辨率选择、`svcH264` 或 `svcH265` 分支、SDK 参数下发以及活动通道回读;不要把编辑了本地文件当作设备已经使用新配置。
在已连接且身份确认的目标上,按 SDK 实际支持情况读取:
```sh
cat /proc/cvitek/venc
cat /proc/cvitek/rc
```
第 8 页图 7 展示 VENC 的 SVC 信息,第 9—11 页图示同时展示 VENC 和 RC。不要假定下表所有字段都在同一个 proc 节点;节点与名称以设备输出为准。
| 观测项 | 用来回答的问题 |
|---|---|
| 通道 ID、分辨率、H.264/H.265、`RcMode`、`Br(kbps)`、`ChgPs` | 当前读到的是否为目标通道,计算输入是否与运行值一致? |
| `svcEnable`、场景识别开关、两项阈值及中等/复杂比例 | SVC 和新的配置是否已下发? |
| `cur_scene_cplx` / `senecCplx` | 当前场景复杂度及换景后的变化是否合理?文档存在不同拼写,按实际输出识别。 |
| `minPercent` | 当前分类实际选用了哪档比例?不能仅凭肉眼认为画面属于中等或复杂。 |
| `realBitrate` | 码控模块当前时刻的实际码率;它不是整段录像的平均码率。 |
| `cur_qp`、QP 上下限、实际帧率 | 是否受到 QP 约束或以掉帧影响测量结果? |
换景后 `cur_scene_cplx` 一直不变时,文档指出 PQ 中关闭 DCI 是一个关键排查方向,应核对 PQ/DCI 并与负责方确认匹配参数;不要只通过扩大两个阈值掩盖统计值不更新的问题。(第 8 页)
## 调参顺序与常见问题
1. 明确本次判据:目标平台、分辨率、编码、主/子码流,目标是相对压降还是绝对码率,以及数值属于上限、下限或允许区间。用户已指定的案例目标优先;不从厂商旧版示例推定当前验收标准。
2. 核对实际模式、SVC 使能、最大码率、`s32ChangePos`、配置分支及切换后回读。该版本采用 AVBR 智能编码时,确认对应 RC 参数已通过 VENC 重建生效。
3. 固定场景观察复杂度统计与 `minPercent`。分类不对才调高低阈值;分类正确时保持阈值不变。
4. 中等/复杂静态码率偏低,分别提高对应 `middle_min_percent` / `complex_min_percent`;按公式反算起点,每轮只调整能解释当前现象的参数并记录前后值。偏高时相反,始终同时观察画质。
5. 比例已改但运行值未变,查加载路径、通道选择、解析映射、接口返回值、VENC 重建及后续覆盖。第 6 页图 5 的参考 demo 仅在 `complex_min_percent > middle_min_percent > 0` 时更新两项比例,阈值更新也要求 `high > low > 0`;核对当前工程是否沿用这些应用层条件,不将其当作 SDK 通用限制。运行比例已变但码流不符合预期,再查场景是否稳定、QP 约束和测量口径。
6. 完成静态测试后检查静转动、动转静的码率恢复与画质,并检查帧率、卡顿、马赛克、拖影、灰屏和相关 AI 功能。这一步能发现只追求压降造成的质量退化。
## 测试方法与判定边界
**相对压降测试**:在同一画面、光照、机位、分辨率、编码、目标帧率和 GOP 条件下,分别采集关闭与开启智能编码的稳定码流。按实现记录两套 RC 参数;文档普通组通常为 VBR、智能组为 AVBR,不能要求两组 RC 模式完全相同而破坏功能。若开关重置了其他参数,先核实并明确差异。对简单、中等、复杂场景分别测量,按需求覆盖 H.264/H.265 和主/子码流。
```text
平均视频码率(kbps)= 统计窗口内的视频负载字节数 × 8 / 时长(秒)/ 1000
压降率(%)= (关闭组平均码率 - 开启组平均码率) / 关闭组平均码率 × 100
```
关闭组平均码率必须大于零。视频流按同一口径统计,排除音频、容器和网络协议开销;如果用例评估音视频总流量,按该口径单列结果,不与纯视频结果混比。文档第 10—11 页实验表分别列了无音频、12k 音频和 64k 音频数据,说明音频会改变总压降比例。
会话建议每组稳定采集约 5 分钟、至少 3 轮,可作为复现实验起点;这不是厂商强制时长,也不能替代全天云存测试。排除开关/重建后的过渡段,以各轮稳定窗口统计结果,并保留采集时长、负载字节数、码率、帧率、画质观察和参数快照。
**绝对码率测试**:直接比较指定场景下开启智能编码后的平均码率与用例范围。文档第 11 页说明旧版杭研方案不要求开关对比,因此不强制为所有项目增加相对压降门槛。
| 判据来源 | 文档或会话中的数值 | 使用边界 |
|---|---|---|
| 文档旧版电信要求(第 4 页) | 简单/中等/复杂压降约 `80%/50%/20%`;全天云存整体约 `50%—70%`;帧率至少 15 fps,GOP 2—4 秒 | 该版本资料描述的要求;全天数值不能由短时静态采集直接证明。 |
| 文档旧版杭研要求(第 4 页) | 3MP 中等/复杂约 `100/170 kbps`;4MP `130/230`;5MP `160/280`;帧率至少 15 fps,GOP 不长于 5 秒 | 旧版目标;动态保持原画质/码率,切换顺畅,简单静态场景当时无要求。 |
| 本会话实际调参目标 | 3MP 中等/复杂 `500/800 kbps`,最大码率 `1250 kbps` | 与文档旧版目标不同;只作为该案例目标,最终仍按实际用例的上下界和容差判定。 |
文档第 9 页说明不同设备 ISP 参数会造成偏差;第 10—11 页图 10、13、15 是厂商模拟自测数据,不能当作当前设备实测证据。报告应写清采用哪版用例、实际运行配置、场景分类、测量口径、各轮结果及画质结论;未进行设备测量时只给调参建议,不写“已达标”。
@@ -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 <新的测试日志目录>` 会在独立本机端口真实打开串口并测试生命周期,默认不发送任何设备数据;先停止本技能原控制台,其他工具占用时按串口准备规则停手,不能强制关闭。该测试会多次正常退出并重新打开自己的控制台,结束后释放串口,不维持生产页面保活。生产服务重新启动后需刷新现有页面加载新模板及新会话令牌。
@@ -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 { }
@@ -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))
@@ -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; });
@@ -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()
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""V1 工厂 TCP 命令客户端;发送任何命令都必须显式使用 --execute。"""
from __future__ import annotations
import argparse
import json
import socket
import struct
import sys
MAX_DATA = 16 * 1024 * 1024
def receive_exact(sock: socket.socket, length: int) -> bytes:
chunks = bytearray()
while len(chunks) < length:
part = sock.recv(length - len(chunks))
if not part:
raise ConnectionError("peer closed before complete V1 frame arrived")
chunks.extend(part)
return bytes(chunks)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("host", help="device IP selected from discovery results")
parser.add_argument("command", help="V1 CMD, decimal or 0x-prefixed")
parser.add_argument("--data", default="", help="UTF-8 JSON/text payload (omit for empty Data)")
parser.add_argument("--timeout", type=float, default=8.0)
parser.add_argument("--execute", action="store_true", help="actually send the command")
args = parser.parse_args()
try:
command = int(args.command, 0)
payload = args.data.encode("utf-8")
if not 0 <= command <= 0xFFFFFFFF:
raise ValueError("CMD must fit uint32")
if len(payload) > MAX_DATA:
raise ValueError("request Data exceeds 16 MiB limit")
if args.data:
json.loads(args.data) # V1 factory command payloads are documented JSON.
if not args.execute:
print(json.dumps({"preflight_only": True, "host": args.host, "port": 4941,
"command": "0x%08X" % command, "data_bytes": len(payload),
"message": "No network connection opened; add --execute to send."}, ensure_ascii=False))
return 0
request = struct.pack("!II", command, len(payload)) + payload
with socket.create_connection((args.host, 4941), timeout=args.timeout) as sock:
sock.settimeout(args.timeout)
sock.sendall(request)
header = receive_exact(sock, 8)
response_command, response_length = struct.unpack("!II", header)
if response_length > MAX_DATA:
raise ValueError("response Data exceeds 16 MiB limit")
response = receive_exact(sock, response_length)
result = {"request_command": "0x%08X" % command,
"response_command": "0x%08X" % response_command,
"response_length": response_length,
"response_data": response.decode("utf-8", errors="replace")}
print(json.dumps(result, ensure_ascii=False))
if response_command != command:
return 2
return 0
except (OSError, ValueError, ConnectionError, json.JSONDecodeError) as exc:
print("V1 command error: %s" % exc, file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""搜索 V1 工厂协议设备(UDP 4942)。"""
from __future__ import annotations
import argparse
import ipaddress
import json
import secrets
import socket
import struct
import time
from typing import Any
PORT = 4942
REQUEST = 0x5A0EFF00
RESPONSE = 0x5A0EFF01
MAX_DATA = 1024 * 1024
def frame(command: int, payload: bytes) -> bytes:
return struct.pack("!II", command, len(payload)) + payload
def parse_frame(packet: bytes) -> tuple[int, bytes]:
if len(packet) < 8:
raise ValueError("packet shorter than V1 header")
command, length = struct.unpack("!II", packet[:8])
if length > MAX_DATA or len(packet) != 8 + length:
raise ValueError("invalid V1 payload length")
return command, packet[8:]
def discover(timeout: float, interface: str | None) -> int:
session = secrets.randbelow(0x7FFFFFFF) + 1
request = frame(REQUEST, json.dumps({"session": session}).encode("utf-8"))
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.settimeout(min(0.5, timeout))
try:
# Bind the documented client port. If another discovery utility owns
# it, stop and report the conflict instead of silently using another port.
sock.bind((interface or "0.0.0.0", PORT))
sock.sendto(request, ("255.255.255.255", PORT))
deadline = time.monotonic() + timeout
seen: set[tuple[str, str]] = set()
while time.monotonic() < deadline:
try:
packet, address = sock.recvfrom(MAX_DATA + 8)
except socket.timeout:
continue
try:
command, data = parse_frame(packet)
obj: Any = json.loads(data.decode("utf-8"))
if command != RESPONSE or not isinstance(obj, dict):
continue
if obj.get("session") != session:
continue
key = (address[0], str(obj.get("sn", "")))
if key in seen:
continue
seen.add(key)
print(json.dumps({"source_ip": address[0], "response": obj}, ensure_ascii=False))
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
continue
return 0
finally:
sock.close()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--timeout", type=float, default=3.0)
parser.add_argument("--interface", help="optional local IPv4 address to bind")
args = parser.parse_args()
if args.timeout <= 0:
parser.error("--timeout must be positive")
if args.interface:
ipaddress.IPv4Address(args.interface)
return discover(args.timeout, args.interface)
if __name__ == "__main__":
raise SystemExit(main())
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-10-21T16:02:37Z"
"syncedAt": "2026-10-22T16:02:41Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "1984f672c01923e26a23ab24063d42a5263b1561",
"commit": "443a9e7abf105be7b9e4bfe77200aebb2d46645f",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-10-21T16:00:00Z"
"syncedAt": "2026-10-22T16:00:00Z"
}
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "604ecdec8dd6a0405e1abdb49565430902373276",
"commit": "622bf0148bca4f697b0184322c7e2aa2a04520a2",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-10-21T16:00:00Z"
"syncedAt": "2026-10-22T16:00:00Z"
}
@@ -2,7 +2,7 @@
name: ppt-master
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
metadata:
version: "6.7.2"
version: "6.7.3"
copyright: "Copyright (c) 2025-2026 Hugo He"
license: "MIT"
official_repository: "https://github.com/hugohe3/ppt-master"
@@ -84,6 +84,8 @@ from server_common import ( # noqa: E402
configure_utf8_stdio()
from annotations import ( # noqa: E402
SVG_NS,
XLINK_NS,
assign_temp_ids,
is_editable_attr,
parse_annotations,
@@ -350,6 +352,19 @@ def _file_digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _register_svg_namespaces() -> None:
"""Bind the SVG namespace to the default prefix right before serializing.
The ElementTree prefix map is process-global and ``pptx_ooxml.ooxml``
(imported via ``svg_to_pptx``) claims the default prefix for its own
namespace at import, so without this the slide comes out as ``<ns0:svg>``.
Call it under ``state_lock``: registration briefly removes the mapping, so
a concurrent serialization could otherwise still emit ``ns0:``.
"""
ET.register_namespace('', SVG_NS)
ET.register_namespace('xlink', XLINK_NS)
def _atomic_replace(path: Path, write) -> None:
"""Write through ``write(tmp_path)`` and swap the result into ``path``.
@@ -885,7 +900,9 @@ def create_app(
tag = tag.split('}', 1)[1]
id_to_tag[eid] = tag
_normalize_preview_hrefs(root)
content = ET.tostring(root, encoding='unicode', xml_declaration=False)
with state_lock:
_register_svg_namespaces()
content = ET.tostring(root, encoding='unicode', xml_declaration=False)
content, warnings = _inline_icons(
content,
icons_dir,
@@ -1201,6 +1218,7 @@ def create_app(
annotated_ids = set(anns.keys())
strip_unused_temp_ids(root, annotated_ids)
_register_svg_namespaces()
try:
_atomic_replace(svg_file, lambda tmp: tree.write(
str(tmp), encoding='UTF-8', xml_declaration=True,
@@ -196,6 +196,34 @@ class StagingDraftTests(unittest.TestCase):
self.assertFalse(self.draft.exists())
class SvgNamespaceTests(unittest.TestCase):
"""Preview and save keep `<svg xmlns=...>` (issue #323)."""
_client = StagingDraftTests._client
_stage = StagingDraftTests._stage
def setUp(self) -> None:
StagingDraftTests.setUp(self)
from xml.etree import ElementTree as ET
from pptx_ooxml.ooxml import REL_NS
# The state a running server is in: pptx_ooxml claims the default prefix at import.
ET.register_namespace('', REL_NS)
self.addCleanup(ET.register_namespace, '', 'http://www.w3.org/2000/svg')
def test_slide_content_keeps_default_svg_namespace(self) -> None:
content = self._client().get('/api/slide/01.svg').get_json()['content']
self.assertTrue(content.startswith('<svg xmlns="http://www.w3.org/2000/svg"'), content[:80])
self.assertNotIn('ns0:', content)
def test_saved_svg_keeps_default_svg_namespace(self) -> None:
client = self._client()
self._stage(client)
self.assertEqual(client.post('/api/save-all').get_json()['files_modified'], ['01.svg'])
on_disk = self.svg_path.read_text(encoding='utf-8')
self.assertIn('<svg xmlns="http://www.w3.org/2000/svg"', on_disk)
self.assertNotIn('ns0:', on_disk)
class AnnotationHelperTests(unittest.TestCase):
def test_user_ids_with_the_temp_prefix_are_kept(self) -> None:
from xml.etree import ElementTree as ET
@@ -2,8 +2,8 @@
"sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"commit": "ba1503c64472b82256d9de95e2e62c5901a38e8b",
"commit": "25af02b1b467fcd53b63e0c6d6d324c57c16ef18",
"adapter": "claude-skill",
"sourcePath": "skills/shadcn",
"syncedAt": "2026-10-21T06:48:34Z"
"syncedAt": "2026-10-22T16:00:00Z"
}
@@ -2,8 +2,8 @@
"sourceId": "shuorenhua",
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
"ref": "main",
"commit": "6c4b9353b87185c26e684c4525be1af47f82fee9",
"commit": "e33d5eb6420ba3468043791a57d71c153b0a8691",
"adapter": "claude-skill",
"sourcePath": ".",
"syncedAt": "2026-10-17T16:00:00Z"
"syncedAt": "2026-10-22T16:00:00Z"
}
@@ -1,6 +1,7 @@
# [UI UX Pro Max](https://uupm.cc)
<p align="center">
<a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/blob/main/README.az.md">🇦🇿 Azərbaycanca</a> |
<a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/blob/main/README.id.md">🇮🇩 Bahasa Indonesia</a> |
<a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/blob/main/README.ko.md">🇰🇷 한국어</a> |
<a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/blob/main/README.vi.md">🇻🇳 Tiếng Việt</a> |
@@ -369,6 +370,32 @@ Make a mobile app UI for e-commerce
Build a fintech banking app with dark theme
```
### Apply a theme to an existing app
After [installing the skill](#installation), open your app in your AI assistant and use a prompt like this. In Workflow Mode, prefix it with `/ui-ux-pro-max`.
```text
Use the ui-ux-pro-max skill to retheme my existing React app.
Target style: Minimalism & Swiss Style.
First inspect the framework and styling-library versions, routes, components,
styles, and existing theme tokens. Keep the current component structure,
behavior, and business logic.
Reuse and update the existing semantic tokens for colors, typography, spacing,
radii, shadows, and interaction states; add missing tokens rather than creating
a parallel theme system. Replace hard-coded styles one component at a time.
Verify contrast, keyboard focus, responsive layouts, and all supported light/dark
modes. Summarize changed files and any remaining hard-coded styles.
```
Replace `Minimalism & Swiss Style` with your preferred [catalog style](src/ui-ux-pro-max/data/styles.csv) or visual direction. Search results provide design guidance; they do not automatically rewrite your app.
**Theme migration checklist:**
- **Audit:** Find theme-related hard-coded values in stylesheets, inline styles, and utilities such as `bg-blue-600` or `text-[#1e293b]`.
- **Map:** Reuse semantic roles such as `background`, `surface`, `foreground`, `primary`, `border`, and `ring` in the app's existing styling system. Add missing roles using its installed library version's conventions.
- **Migrate:** Replace hard-coded values with token references in small component batches. Changing tokens alone will not affect styles that do not use them.
- **Verify:** Check text/UI contrast, hover/focus/disabled states, responsive layouts, and each supported theme after every batch.
### How It Works
1. **You ask** - Request any UI/UX task (build, design, create, implement, review, fix, improve)
@@ -2,8 +2,8 @@
"sourceId": "ui-ux-pro-max",
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
"ref": "main",
"commit": "56ea9cf174ed86786187daa9de0e6247cb0747e4",
"commit": "623a9a9037d006ea727f8213f084d02a6b6f11b5",
"adapter": "claude-skill",
"sourcePath": ".claude/skills/ui-ux-pro-max",
"syncedAt": "2026-10-15T03:03:03Z"
"syncedAt": "2026-10-22T16:00:00Z"
}