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:
1 parent
949a960835
commit
ed52dd300b
30 files changed
+1249
-86
No files matched your search
@@ -105,8 +105,8 @@
|
|||||||
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "56ea9cf174ed86786187daa9de0e6247cb0747e4",
|
"commit": "623a9a9037d006ea727f8213f084d02a6b6f11b5",
|
||||||
"syncedAt": "2026-10-15T03:03:03Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "caveman",
|
"id": "caveman",
|
||||||
@@ -132,8 +132,8 @@
|
|||||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "ba1503c64472b82256d9de95e2e62c5901a38e8b",
|
"commit": "25af02b1b467fcd53b63e0c6d6d324c57c16ef18",
|
||||||
"syncedAt": "2026-10-21T06:48:34Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "frontend-slides",
|
"id": "frontend-slides",
|
||||||
@@ -168,8 +168,8 @@
|
|||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "604ecdec8dd6a0405e1abdb49565430902373276",
|
"commit": "622bf0148bca4f697b0184322c7e2aa2a04520a2",
|
||||||
"syncedAt": "2026-10-21T16:00:00Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "grill-me",
|
"id": "grill-me",
|
||||||
@@ -220,19 +220,19 @@
|
|||||||
"id": "artemis-automation",
|
"id": "artemis-automation",
|
||||||
"pluginName": "artemis-automation",
|
"pluginName": "artemis-automation",
|
||||||
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
||||||
"ref": "v2.0.0",
|
"ref": "v2.0.1",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "973ccae679b082f9ac5df85b6c2ea272ba29b828",
|
"commit": "b584dae80aaae66607d6335bf1efa4b847f1955b",
|
||||||
"syncedAt": "2026-10-21T06:48:34Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "embedded-app-pc-debug",
|
"id": "embedded-app-pc-debug",
|
||||||
"pluginName": "embedded-app-pc-debug",
|
"pluginName": "embedded-app-pc-debug",
|
||||||
"repo": "https://git.playones.com/zoukai/embedded-app-pc-debug.git",
|
"repo": "https://git.playones.com/zoukai/embedded-app-pc-debug.git",
|
||||||
"ref": "v1.0.0",
|
"ref": "v1.0.1",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "cfb586cb5ffc5a69e66cd9adbc57e4ebf725d1d3",
|
"commit": "961ccbe8e0bc7473c99f052e5502ae9e89388cbf",
|
||||||
"syncedAt": "2026-09-15T09:16:34Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "firmware-app-development",
|
"id": "firmware-app-development",
|
||||||
@@ -258,8 +258,8 @@
|
|||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "1984f672c01923e26a23ab24063d42a5263b1561",
|
"commit": "443a9e7abf105be7b9e4bfe77200aebb2d46645f",
|
||||||
"syncedAt": "2026-10-21T16:00:00Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "shuorenhua",
|
"id": "shuorenhua",
|
||||||
@@ -267,8 +267,8 @@
|
|||||||
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "6c4b9353b87185c26e684c4525be1af47f82fee9",
|
"commit": "e33d5eb6420ba3468043791a57d71c153b0a8691",
|
||||||
"syncedAt": "2026-10-17T16:00:00Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-automation",
|
"name": "artemis-automation",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。",
|
"description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "chenmingxuan",
|
"name": "chenmingxuan",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"sourceId": "artemis-automation",
|
"sourceId": "artemis-automation",
|
||||||
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
||||||
"ref": "v2.0.0",
|
"ref": "v2.0.1",
|
||||||
"commit": "973ccae679b082f9ac5df85b6c2ea272ba29b828",
|
"commit": "b584dae80aaae66607d6335bf1efa4b847f1955b",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/artemis-use",
|
"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",
|
"name": "embedded-app-pc-debug",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "Windows 电脑端嵌入式应用与固件调试流程,覆盖构建、发现、串口、部署、升级和诊断。",
|
"description": "Windows 电脑端嵌入式应用与固件调试流程,覆盖构建、发现、串口、部署、升级和诊断。",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "zoukai",
|
"name": "zoukai",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"sourceId": "embedded-app-pc-debug",
|
"sourceId": "embedded-app-pc-debug",
|
||||||
"repo": "https://git.playones.com/zoukai/embedded-app-pc-debug.git",
|
"repo": "https://git.playones.com/zoukai/embedded-app-pc-debug.git",
|
||||||
"ref": "v1.0.0",
|
"ref": "v1.0.1",
|
||||||
"commit": "cfb586cb5ffc5a69e66cd9adbc57e4ebf725d1d3",
|
"commit": "961ccbe8e0bc7473c99f052e5502ae9e89388cbf",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": ".",
|
"sourcePath": ".",
|
||||||
"syncedAt": "2026-09-15T09:16:34Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: embedded-app-pc-debug
|
name: embedded-app-pc-debug
|
||||||
description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编译、NFS 模块部署、开机进程与加密参数调试,以及设备发现、ROM 升级、串口监测和故障诊断。适用于 Eapil/CamTells 工作流;普通代码开发或仅查看已有日志时不要自动触发。
|
description: 用于 Windows 电脑端的 Eapil 嵌入式产品与平台调试,包括远程编译、NFS 模块部署、智能编码调试、设备发现、ROM 升级、串口监测和故障诊断;普通代码开发或仅查看已有日志时不要自动触发。
|
||||||
---
|
---
|
||||||
|
|
||||||
# Windows电脑端调试
|
# Windows电脑端调试
|
||||||
@@ -13,14 +13,17 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
|
|||||||
- 涉及 NFS 模块部署、`DxMain`、开机启动脚本、工厂模式或加密 param 时,读取 [references/application-debugging.md](references/application-debugging.md)。
|
- 涉及 NFS 模块部署、`DxMain`、开机启动脚本、工厂模式或加密 param 时,读取 [references/application-debugging.md](references/application-debugging.md)。
|
||||||
- 涉及广播搜索、基础信息、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)。
|
- 涉及串口监测、升级成功判据或故障定位时,读取 [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)。
|
- 涉及 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`
|
- SSH 配置主机:`emb.eapil.com`
|
||||||
- 远程工程搜索根:优先现有工作区、`~/work/develop/gitcode` 和实际挂载点;`/CamTells` 仅是兼容候选
|
- 远程工程搜索根:优先现有工作区、`~/work/develop/gitcode` 和实际挂载点;产品目录(如 `/CamTells`)只是候选之一
|
||||||
- 默认 Public 来源:`~/work/develop/gitcode/EMX2.1`
|
- 默认 Public 来源:`~/work/develop/gitcode/EMX2.1`
|
||||||
- 本地归档根目录:`F:\tmp\embedded-app-pc-debug`
|
- 本地归档根目录:`F:\tmp\embedded-app-pc-debug`
|
||||||
- 串口默认值:`115200`、`8N1`、无流控
|
- 串口默认值:`115200`、`8N1`、无流控
|
||||||
@@ -30,17 +33,38 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
|
|||||||
## 工作方式
|
## 工作方式
|
||||||
|
|
||||||
1. 根据用户请求确定需要执行的阶段;不强迫每次都完成整条链路。
|
1. 根据用户请求确定需要执行的阶段;不强迫每次都完成整条链路。
|
||||||
2. 自己调查文件、脚本参数、网卡和设备响应等可查事实,只向用户询问真正需要选择的事项。新设备调试开始前先枚举串口并让用户选择串口或 Telnet;即使只有一个 USB 串口也不自动打开。串口必须取得本次用户确认的 COM 口;Telnet 必须先取得完整 SN 和型号。
|
2. 自己调查文件、脚本参数、网卡和设备响应等可查事实,只向用户询问真正需要选择的事项。新设备调试开始前先枚举串口并让用户选择串口或 Telnet;用户已明确选择串口时只确认尚缺的 COM 口,即使只有一个 USB 串口也不自行挑选。同一调试任务、目标和参数未变时复用已确认的 COM 口,不反复询问;更换设备、端口或目标不明确时重新确认。Telnet 必须先取得完整 SN 和型号。
|
||||||
3. 将每次完整任务归档到 `<根目录>\<项目>\<YYYYMMDD-HHmmss>`,分别保存 `firmware` 和 `logs`。
|
3. 将每次完整任务归档到 `<根目录>\<项目>\<YYYYMMDD-HHmmss>`,分别保存 `firmware` 和 `logs`。
|
||||||
4. 搜索可以按型号、IP 或唯一响应列出候选,但这些条件都不能替代完整 SN。Telnet、升级及其他设备写操作必须以用户给出的完整 SN 精确匹配目标,并核对型号;多个候选或同一 SN 出现冲突时停止,不能默认第一台。
|
4. 搜索可以按型号、IP 或唯一响应列出候选,但这些条件都不能替代完整 SN。Telnet、升级及其他设备写操作必须以用户给出的完整 SN 精确匹配目标,并核对型号;多个候选或同一 SN 出现冲突时停止,不能默认第一台。
|
||||||
5. 先启动串口监测,再执行设备升级;串口输出同时实时显示并保存日志。需要在网页中查看日志或向设备输入命令时,使用单进程串口网页控制台,避免采集进程和发送进程争用同一个 COM 口。网页首次打开显示最新日志,所有发送内容都写入文本日志,空输入也必须发送真实 `CR`。刷新网页不能中断采集;多个页面共用服务时仅关闭最后一个页面才触发停止。最后页面关闭后,即使处于升级期间,也要在约 `5` 秒内结束采集、保留日志并释放 COM 口;之后必须重新启动控制台才能再次连接。
|
5. 先启动串口监测,再执行设备升级;串口输出同时实时显示并保存日志。默认使用可见的单进程串口网页控制台,先检查已有服务,正常则在同一页面复用,避免重复开窗口或启动第二个串口进程。网页首次打开显示最新日志,所有发送内容都写入文本日志,空输入也必须发送真实 `CR`。刷新网页不能中断采集;多个页面共用服务时仅关闭最后一个页面才触发停止。最后页面关闭后,即使处于升级期间,也要在约 `5` 秒内结束采集、保留日志并释放 COM 口;之后必须重新启动控制台才能再次连接。
|
||||||
6. 在 ROM 传输前集中显示目标 SN、型号、IP、当前版本、ROM 绝对路径、大小、SHA-256、可解析的目标版本、串口状态和所有无法验证的项目。
|
6. 在 ROM 传输前集中显示目标 SN、型号、IP、当前版本、ROM 绝对路径、大小、SHA-256、可解析的目标版本、串口状态和所有无法验证的项目。
|
||||||
7. 只有用户在看到上述信息后明确确认升级,才能传输 ROM、烧录或触发重启。之前对搜索、编译或诊断的授权不能替代这次确认。
|
7. 只有用户在看到上述信息后明确确认升级,才能传输 ROM、烧录或触发重启。之前对搜索、编译或诊断的授权不能替代这次确认。
|
||||||
8. 升级失败后先诊断并保留证据,不自动重试;再次升级需要新的明确确认。
|
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 与构建边界
|
## Git 与构建边界
|
||||||
|
|
||||||
@@ -49,6 +73,8 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
|
|||||||
- 默认执行 `EmxGitInfo` 并保存输出作为版本凭证;失败时记录原因。临时验证包可继续,正式发布是否继续由用户决定。
|
- 默认执行 `EmxGitInfo` 并保存输出作为版本凭证;失败时记录原因。临时验证包可继续,正式发布是否继续由用户决定。
|
||||||
- 不使用旧 ROM 冒充本次产物。构建脚本若明确宣告 ROM 目标成功并验证了本次 ROM,之后仅辅助全量镜像步骤失败时,可以交付“仅供局域网升级的 ROM”,但不能声称完整发布包成功。
|
- 不使用旧 ROM 冒充本次产物。构建脚本若明确宣告 ROM 目标成功并验证了本次 ROM,之后仅辅助全量镜像步骤失败时,可以交付“仅供局域网升级的 ROM”,但不能声称完整发布包成功。
|
||||||
|
|
||||||
|
仅编译指定模块时,使用当前工程已确认的交叉工具链和模块构建目标,不强制走完整 ROM 打包流程,详见 [构建参考文档](references/build-workflow.md)。NFS 联调需保留用户配置,并核对实际运行文件、启动参数和依赖,详见 [应用调试参考文档](references/application-debugging.md);编译成功不能代替设备运行及功能验收。
|
||||||
|
|
||||||
## 工具脚本
|
## 工具脚本
|
||||||
|
|
||||||
- `scripts/device_upgrade.py`:V2 `30101` 身份预检与 `EPFP/4001` 升级。缺少 `--execute` 时绝不发送 ROM。
|
- `scripts/device_upgrade.py`:V2 `30101` 身份预检与 `EPFP/4001` 升级。缺少 `--execute` 时绝不发送 ROM。
|
||||||
@@ -68,3 +94,5 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编
|
|||||||
不要把连接建立、ROM 发送结束、下载进度达到 100% 或 Flash 计数达到总量单独称为升级成功。至少需要明确的协议成功终态,并继续核对同一 SN 的重启、重发现、网络恢复和目标版本;无法取得某项证据时明确说明,不补造结论。
|
不要把连接建立、ROM 发送结束、下载进度达到 100% 或 Flash 计数达到总量单独称为升级成功。至少需要明确的协议成功终态,并继续核对同一 SN 的重启、重发现、网络恢复和目标版本;无法取得某项证据时明确说明,不补造结论。
|
||||||
|
|
||||||
串口网页控制台发生变更时,还要验证服务代码能编译、Skill 结构有效、COM 口由单一进程占用、服务实际连接、接收字节或日志文件持续增长、普通发送与空回车都有 TX 记录,并确认页面首次打开定位到最新日志。还必须完成生命周期验收:刷新不中断、两个页面关闭一个仍运行、关闭最后页面后约 `5` 秒内进程退出且状态接口离线、日志具有结束标记、原日志与原始字节文件仍在原路径,并且下一次服务能重新打开同一 COM 口。仅看到服务进程或空白网页不算验证通过。
|
串口网页控制台发生变更时,还要验证服务代码能编译、Skill 结构有效、COM 口由单一进程占用、服务实际连接、接收字节或日志文件持续增长、普通发送与空回车都有 TX 记录,并确认页面首次打开定位到最新日志。还必须完成生命周期验收:刷新不中断、两个页面关闭一个仍运行、关闭最后页面后约 `5` 秒内进程退出且状态接口离线、日志具有结束标记、原日志与原始字节文件仍在原路径,并且下一次服务能重新打开同一 COM 口。仅看到服务进程或空白网页不算验证通过。
|
||||||
|
|
||||||
|
上述串口行为验收适用于服务或页面实现变更;仅修改技能文档时检查结构、引用及规则一致性,不为文档校验打断现有采集。报告区分代码检查、替身单测、真实串口协议测试和实际浏览器操作验收;未执行的项明确标注,不以自动化结果代替完整界面验收。
|
||||||
+70
-14
@@ -53,6 +53,9 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
tab-size: 4;
|
tab-size: 4;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
font: 13px/1.42 Consolas, "Cascadia Mono", "Microsoft YaHei UI", monospace;
|
font: 13px/1.42 Consolas, "Cascadia Mono", "Microsoft YaHei UI", monospace;
|
||||||
}
|
}
|
||||||
.console {
|
.console {
|
||||||
@@ -176,6 +179,8 @@
|
|||||||
let sendBusy = false;
|
let sendBusy = false;
|
||||||
let promptMode = 'unknown';
|
let promptMode = 'unknown';
|
||||||
let pageClosing = false;
|
let pageClosing = false;
|
||||||
|
let livenessConnection = null;
|
||||||
|
let selectingLog = false;
|
||||||
|
|
||||||
commandPromptView.textContent = `${configuredPort} >`;
|
commandPromptView.textContent = `${configuredPort} >`;
|
||||||
|
|
||||||
@@ -184,6 +189,29 @@
|
|||||||
return `${path}${separator}client=${encodeURIComponent(clientId)}`;
|
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) {
|
function removeTerminalControls(text) {
|
||||||
text = text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '');
|
text = text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '');
|
||||||
@@ -250,42 +278,57 @@
|
|||||||
updateInputControls();
|
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() {
|
async function refreshLog() {
|
||||||
if (logPolling) return;
|
if (pageClosing || logPolling) return;
|
||||||
logPolling = true;
|
logPolling = true;
|
||||||
const nearBottom = logView.scrollHeight - logView.scrollTop - logView.clientHeight < 100;
|
|
||||||
const shouldFollowLatest = followView.checked && (!hasRenderedLog || nearBottom);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${clientUrl('/api/log')}&t=${Date.now()}`, { cache: 'no-store' });
|
const bytes = new Uint8Array(await fetchLocal(`${clientUrl('/api/log')}&t=${Date.now()}`));
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
||||||
const fullText = removeTerminalControls(decoder.decode(bytes));
|
const fullText = removeTerminalControls(decoder.decode(bytes));
|
||||||
updateSessionState(fullText);
|
updateSessionState(fullText);
|
||||||
const visibleText = fullText.length > 500000
|
const visibleText = fullText.length > 500000
|
||||||
? `[前面的日志已在页面中省略,完整数据仍保存在文件中]\n${fullText.slice(-500000)}`
|
? `[前面的日志已在页面中省略,完整数据仍保存在文件中]\n${fullText.slice(-500000)}`
|
||||||
: fullText;
|
: 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) {
|
if (shouldFollowLatest) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
logView.scrollTop = logView.scrollHeight;
|
if (!isLogSelectionActive()) logView.scrollTop = logView.scrollHeight;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
hasRenderedLog = true;
|
hasRenderedLog = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logView.textContent = `${logView.textContent}\n[网页] 日志读取失败:${error.message}`;
|
if (!isLogSelectionActive()) {
|
||||||
|
logView.textContent = `${logView.textContent}\n[网页] 日志读取失败:${error.message}`;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
logPolling = false;
|
logPolling = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
if (statusPolling) return;
|
if (pageClosing || statusPolling) return;
|
||||||
statusPolling = true;
|
statusPolling = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${clientUrl('/api/status')}&t=${Date.now()}`, { cache: 'no-store' });
|
const bytes = await fetchLocal(`${clientUrl('/api/status')}&t=${Date.now()}`);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
const status = JSON.parse(decoder.decode(bytes));
|
||||||
const status = await response.json();
|
|
||||||
applyConnectionState(Boolean(status.isOpen));
|
applyConnectionState(Boolean(status.isOpen));
|
||||||
connectionView.textContent = status.isOpen ? `● ${status.port} 已连接` : `● ${status.port} 已断开`;
|
connectionView.textContent = status.isOpen ? `● ${status.port} 已连接` : `● ${status.port} 已断开`;
|
||||||
connectionView.style.color = status.isOpen ? 'var(--ok)' : 'var(--danger)';
|
connectionView.style.color = status.isOpen ? 'var(--ok)' : 'var(--danger)';
|
||||||
@@ -343,6 +386,10 @@
|
|||||||
function disconnectClient() {
|
function disconnectClient() {
|
||||||
if (pageClosing) return;
|
if (pageClosing) return;
|
||||||
pageClosing = true;
|
pageClosing = true;
|
||||||
|
if (livenessConnection) {
|
||||||
|
livenessConnection.close();
|
||||||
|
livenessConnection = null;
|
||||||
|
}
|
||||||
const disconnectUrl = `${clientUrl('/api/client/disconnect')}&token=${encodeURIComponent(token)}`;
|
const disconnectUrl = `${clientUrl('/api/client/disconnect')}&token=${encodeURIComponent(token)}`;
|
||||||
// 浏览器关闭标签页时普通异步请求可能被直接取消;sendBeacon 专门用于离页通知。
|
// 浏览器关闭标签页时普通异步请求可能被直接取消;sendBeacon 专门用于离页通知。
|
||||||
// 某些内置浏览器未实现 sendBeacon,因此同时保留 keepalive fetch 作为兼容兜底。
|
// 某些内置浏览器未实现 sendBeacon,因此同时保留 keepalive fetch 作为兼容兜底。
|
||||||
@@ -369,10 +416,16 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
sendButton.addEventListener('click', sendCommand);
|
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', () => {
|
followView.addEventListener('change', () => {
|
||||||
if (followView.checked) {
|
if (followView.checked) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
logView.scrollTop = logView.scrollHeight;
|
if (!isLogSelectionActive()) logView.scrollTop = logView.scrollHeight;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -408,9 +461,12 @@
|
|||||||
window.addEventListener('beforeunload', disconnectClient);
|
window.addEventListener('beforeunload', disconnectClient);
|
||||||
window.addEventListener('pageshow', () => {
|
window.addEventListener('pageshow', () => {
|
||||||
pageClosing = false;
|
pageClosing = false;
|
||||||
|
connectLiveness();
|
||||||
|
refreshLog();
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connectLiveness();
|
||||||
refreshLog();
|
refreshLog();
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
setInterval(refreshLog, 800);
|
setInterval(refreshLog, 800);
|
||||||
|
|||||||
+22
@@ -33,6 +33,13 @@ mount -t nfs <服务器IP>:/nfs/<用户名> /mnt/nfs -o nolock
|
|||||||
7. NFS 已挂载时必须与本次确认的服务器和导出路径完全一致。源程序必须通过 ELF 架构和解释器检查;设备缺少 `readelf` 时默认停止,只有人工看过有限检查结果后才可显式放行。
|
7. NFS 已挂载时必须与本次确认的服务器和导出路径完全一致。源程序必须通过 ELF 架构和解释器检查;设备缺少 `readelf` 时默认停止,只有人工看过有限检查结果后才可显式放行。
|
||||||
8. 在线停止已有进程时必须提供从当前设备实查得到的失败恢复命令。脚本会在替换失败时尝试恢复原文件和原进程,并在设备 `/tmp/codex-module-<模块>-<动作>-<时间>.state` 记录事务阶段;出现命令超时、会话中断、掉电或 `ROLLBACK_FAILED` 时状态未知,禁止直接重试,必须重新核对设备身份后读取事务状态,检查进程、目标哈希和备份,不能宣称已自动恢复。
|
8. 在线停止已有进程时必须提供从当前设备实查得到的失败恢复命令。脚本会在替换失败时尝试恢复原文件和原进程,并在设备 `/tmp/codex-module-<模块>-<动作>-<时间>.state` 记录事务阶段;出现命令超时、会话中断、掉电或 `ROLLBACK_FAILED` 时状态未知,禁止直接重试,必须重新核对设备身份后读取事务状态,检查进程、目标哈希和备份,不能宣称已自动恢复。
|
||||||
|
|
||||||
|
### NFS 联调的配置与运行证据
|
||||||
|
|
||||||
|
- 替换前记录现有程序路径、PID、启动参数、配置工作目录、必要环境开关、动态库来源和恢复方式。用户手工调整的上线条件、持续运行设置、服务器地址等配置保持不变;不能因重新部署二进制而用工程默认文件覆盖。仅在用户明确要求修改某字段时最小化更改该字段,记录配置哈希与差异,不连带修改其他设置。
|
||||||
|
- 新版本沿用当前任务确认的启动方式和配置目录;配置依赖相对路径时,不能只切换到 NFS 程序目录启动。核对动态加载器、库路径、必要 `.so` 和符号链接,不能因文件已复制或程序短暂出现就认为依赖齐全。
|
||||||
|
- 启动后记录 PID,并在可用时通过 `/proc/<pid>/exe`、`cmdline`、`cwd` 核对实际运行文件、参数和工作目录;计算对应部署文件的 SHA-256,必要时检查 `maps` 中实际加载的库来源。不能仅凭进程名、旧日志标记或启动命令返回 0 判断新程序已生效。
|
||||||
|
- 观察常驻进程持续存活及目标业务结果,区分构建、拷贝、启动和功能验证的结论。已有配置未要求变更时核对前后哈希一致;未验证的功能明确列为待验证,不扩张为整机或全部业务通过。只重启明确授权的模块,不把整机重启或其他关键服务重启作为默认联调步骤。
|
||||||
|
|
||||||
## DxMain 与 daemon.sh
|
## DxMain 与 daemon.sh
|
||||||
|
|
||||||
不要在 `daemon.sh` 或其后台软件看门狗仍运行时停止 `DxMain`。守护逻辑可能重新启动 `DxMain`,关键服务丢失时还可能执行强制重启。
|
不要在 `daemon.sh` 或其后台软件看门狗仍运行时停止 `DxMain`。守护逻辑可能重新启动 `DxMain`,关键服务丢失时还可能执行强制重启。
|
||||||
@@ -59,6 +66,21 @@ mount -t nfs <服务器IP>:/nfs/<用户名> /mnt/nfs -o nolock
|
|||||||
5. 重启前串口必须已经打开并确实收到数据,同时给出可以通过串口执行的恢复命令。完成修改不等于取得重启授权;集中展示变更、备份和预期后等待明确的“确认重启”。
|
5. 重启前串口必须已经打开并确实收到数据,同时给出可以通过串口执行的恢复命令。完成修改不等于取得重启授权;集中展示变更、备份和预期后等待明确的“确认重启”。
|
||||||
6. 重启后通过串口观察完整启动过程,核对目标进程、守护进程、工厂程序、异常退出、panic、看门狗和循环重启;网络恢复后再使用协议或 Telnet 辅助验证。
|
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` 实查。
|
未烧录设备可能自动进入产测流程;已烧录设备通常进入正常流程,只有强制工厂标志生效时才运行产测程序。具体判断可能使用 MAC/SN 文件、文件大小、加密烧录数据、环境变量或标志文件,必须从当前工程和设备的 `function.sh`、`appinit.sh`、`allstart.sh` 实查。
|
||||||
|
|||||||
+12
-4
@@ -1,13 +1,13 @@
|
|||||||
# 远程打包与产物归档
|
# 远程打包与产物归档
|
||||||
|
|
||||||
仅在任务涉及编译新固件或下载远程产物时读取本文件。
|
仅在任务涉及远程编译(完整固件或单模块)或下载远程产物时读取本文件。
|
||||||
|
|
||||||
## 默认值与覆盖
|
## 默认值与覆盖
|
||||||
|
|
||||||
| 项目 | 默认值 |
|
| 项目 | 默认值 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| SSH Host | `emb.eapil.com` |
|
| SSH Host | `emb.eapil.com` |
|
||||||
| 工程搜索根 | 当前远程工作区、`~/work/develop/gitcode`、实际挂载点;`/CamTells` 仅作兼容候选 |
|
| 工程搜索根 | 当前远程工作区、`~/work/develop/gitcode`、实际挂载点;可把已知产品目录(例如 `/CamTells`)作为候选之一,不限于该目录 |
|
||||||
| Public 来源 | `~/work/develop/gitcode/EMX2.1` |
|
| Public 来源 | `~/work/develop/gitcode/EMX2.1` |
|
||||||
| 本地归档根目录 | `F:\tmp\embedded-app-pc-debug` |
|
| 本地归档根目录 | `F:\tmp\embedded-app-pc-debug` |
|
||||||
|
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
## 工程定位
|
## 工程定位
|
||||||
|
|
||||||
1. 先用现有 SSH/VS Code 配置解析 Host,不猜测用户名、端口或私钥路径。
|
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` 等变量。记录每个搜索根的退出码并明确区分:没有匹配、无权访问、路径未挂载、命令不存在和搜索超时。超时不等于项目不存在。
|
3. PowerShell 调用远端 Shell 时,避免让本地提前展开远端 `$HOME` 等变量。记录每个搜索根的退出码并明确区分:没有匹配、无权访问、路径未挂载、命令不存在和搜索超时。超时不等于项目不存在。
|
||||||
4. 如果有多个候选,列出远程绝对路径等待选择;不要凭目录修改时间挑选。
|
4. 如果有多个候选,列出远程绝对路径等待选择;不要凭目录修改时间挑选。
|
||||||
5. 确认工程目录包含待用脚本,再确定真实命令行参数。类似 `product/CamTells/EPM21C` 的深层产品目录不能因浅层 `find -maxdepth` 被排除。
|
5. 确认工程目录包含待用脚本,再确定真实命令行参数。产品目录可能较深,例如 `product/<产品线>/<项目>`;不能因浅层 `find -maxdepth` 排除有效工程。
|
||||||
|
|
||||||
## 构建顺序
|
## 构建顺序
|
||||||
|
|
||||||
@@ -31,6 +31,14 @@
|
|||||||
6. 尊重构建脚本内部的校验与重建循环。脚本因随机命中、签名或封装检查而自动重建时,让脚本自行完成;不要在外层重复启动、重复改版本或把一次内部重建误报为多个发布版本。
|
6. 尊重构建脚本内部的校验与重建循环。脚本因随机命中、签名或封装检查而自动重建时,让脚本自行完成;不要在外层重复启动、重复改版本或把一次内部重建误报为多个发布版本。
|
||||||
7. 构建失败时不下载旧产物。若本次 ROM 已由脚本明确生成、校验通过,而随后仅全量烧录镜像或其他辅助目标失败,应分开报告:允许交付已验证 ROM 用于局域网升级,但完整发布包仍判为失败或不完整。
|
7. 构建失败时不下载旧产物。若本次 ROM 已由脚本明确生成、校验通过,而随后仅全量烧录镜像或其他辅助目标失败,应分开报告:允许交付已验证 ROM 用于局域网升级,但完整发布包仍判为失败或不完整。
|
||||||
|
|
||||||
|
## 单模块交叉编译与 NFS 交付
|
||||||
|
|
||||||
|
用户只要求编译某个应用或库时,不强制执行完整固件的 `product.py`、`release.py` 流程。优先复用当前远程工程已验证的构建目录和目标,核对其源码目录、交叉工具链、目标架构及必要构建选项;缓存指向其他工程或工具链时先说明冲突,不直接覆盖或继续编译。
|
||||||
|
|
||||||
|
记录实际构建目标、命令、退出码、本次产物的绝对路径、大小、SHA-256 和 ELF 信息。按运行需求交付必要动态库及其链接关系,不把静态库、开发头文件或旧目录中的同名文件一概当成设备运行依赖。
|
||||||
|
|
||||||
|
用户指定 NFS 导出目录时,在该目录下使用独立的本次产物目录,保留旧程序和依赖;避免覆盖设备正在从 NFS 执行的二进制。拷贝后核对源与目标的哈希及执行权限,并明确区分“远端构建完成”“NFS 产物已就绪”“设备已运行”“功能验证通过”。设备验证步骤见 [应用模块调试](application-debugging.md),不能因完成拷贝就宣称验证通过。
|
||||||
|
|
||||||
## 打包任务交付清单
|
## 打包任务交付清单
|
||||||
|
|
||||||
Linux 打包任务应一次性交付以下事实,供 Windows 设备任务直接使用:
|
Linux 打包任务应一次性交付以下事实,供 Windows 设备任务直接使用:
|
||||||
|
|||||||
+12
@@ -23,6 +23,18 @@
|
|||||||
- 读取不到任务正文时,先核对任务 ID 和主机 ID,再查看最新任务状态。只有任务工具确实不能返回内容时,才在已授权主机上读取对应会话记录或请用户转交摘要。
|
- 读取不到任务正文时,先核对任务 ID 和主机 ID,再查看最新任务状态。只有任务工具确实不能返回内容时,才在已授权主机上读取对应会话记录或请用户转交摘要。
|
||||||
- 任务 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
|
```text
|
||||||
|
|||||||
+62
-1
@@ -11,6 +11,65 @@
|
|||||||
- `C:\Users\ZK\Downloads\设备端TCP文件传输类协议.md`
|
- `C:\Users\ZK\Downloads\设备端TCP文件传输类协议.md`
|
||||||
- `C:\Users\ZK\Downloads\工厂协议V1.md`
|
- `C:\Users\ZK\Downloads\工厂协议V1.md`
|
||||||
- `E:\CodeSpace\git-pro\eapil_qt_player_v2\EpProtocolDemo`
|
- `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 协议矩阵
|
## V2 协议矩阵
|
||||||
|
|
||||||
@@ -37,7 +96,7 @@ V2 发现请求 JSON:
|
|||||||
- `outer.type`
|
- `outer.type`
|
||||||
- `hardware.type`
|
- `hardware.type`
|
||||||
|
|
||||||
非关键网络字段允许缺失。完整 SN 是升级身份核对的首要依据。V1 的端口与帧格式必须从 V1 文档或已验证的 `EpProtocolDemo/V1` 获取,不能套用 V2。
|
非关键网络字段允许缺失。完整 SN 是升级身份核对的首要依据。V1 的端口与帧格式见上文 V1 章节,并以目标设备对应的 V1 文档或源码复核;不能套用 V2。
|
||||||
|
|
||||||
执行发现时使用 `scripts/v2_discover.py`,并遵守以下规则:
|
执行发现时使用 `scripts/v2_discover.py`,并遵守以下规则:
|
||||||
|
|
||||||
@@ -100,6 +159,8 @@ uint8 status
|
|||||||
- 多个 ROM 或设备候选时等待选择。搜索端口被占用或没有完成完整 SN 核对时不得升级。
|
- 多个 ROM 或设备候选时等待选择。搜索端口被占用或没有完成完整 SN 核对时不得升级。
|
||||||
- 确认后调用 `scripts/device_upgrade.py --execute`,不绕过其保护参数。
|
- 确认后调用 `scripts/device_upgrade.py --execute`,不绕过其保护参数。
|
||||||
|
|
||||||
|
串口登录可以确认现场设备、读取版本并恢复网络,但不能替代升级前的 `30101` 身份响应。即使 COM 口已由用户确认且串口内的 SN、型号看似匹配,`30101` 超时、返回不完整或不匹配时仍不得发送 ROM。先检查设备端端口监听、接口 MAC、关联、DHCP、PC 多网卡选路和二层可达性;恢复后重新运行标准预检。
|
||||||
|
|
||||||
## 已知协议歧义
|
## 已知协议歧义
|
||||||
|
|
||||||
- 状态章节没有明确说明每次状态是否带 EPFP 外层头,因此客户端兼容完整帧和裸状态。
|
- 状态章节没有明确说明每次状态是否带 EPFP 外层头,因此客户端兼容完整帧和裸状态。
|
||||||
|
|||||||
+25
@@ -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 记录时保留扫描器原始目录分组;注意长字段可能需要确认内容未被截断。
|
||||||
|
- 预填完成后重新读取表单字段,确认文本内容和换行完整。用户只授权预填时,不点击“提交”或“保存草稿”,并在回复中明确说明记录仍未提交/保存。
|
||||||
+122
@@ -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 是厂商模拟自测数据,不能当作当前设备实测证据。报告应写清采用哪版用例、实际运行配置、场景分类、测量口径、各轮结果及画质结论;未进行设备测量时只给调参建议,不写“已达标”。
|
||||||
+75
-8
@@ -4,22 +4,55 @@
|
|||||||
|
|
||||||
## 串口准备
|
## 串口准备
|
||||||
|
|
||||||
1. 每次串口任务先请用户确认当前 COM 口;可以枚举 Windows 串口及描述帮助确认,但不要沿用上一次的端口或自行挑选。
|
1. 新设备调试先取得本次用户确认的 COM 口,可以枚举 Windows 串口及描述帮助确认,但不自行挑选。同一调试任务、设备和参数未变时沿用已确认的端口,不因执行下一条命令或恢复页面而反复询问;更换设备、端口或目标不明确时重新确认,不从其他任务或历史记忆猜测端口。
|
||||||
2. 默认 `115200`、8 数据位、1 停止位、无校验、无流控,允许覆盖。
|
2. 默认 `115200`、8 数据位、1 停止位、无校验、无流控,允许覆盖。
|
||||||
3. 打开失败、打开后没有输出、乱码或访问拒绝时立即停手,请用户核对 COM 口、波特率/校验/数据位/停止位/流控、端口是否已释放,必要时重新插拔或更换转接器。不要强制结束 SecureCRT、串口工具或其他进程。
|
3. 页面异常、打开失败、无输出或乱码时,先按下文“异常时的本地只读诊断”定位层次;确认端口打不开、占用或参数可疑后停手,请用户核对 COM 口和参数。不要强制结束 SecureCRT、串口工具或其他进程,不反复重连或自行重启设备。
|
||||||
4. 只读采集使用 `scripts/serial_monitor.ps1`;需要网页实时显示或网页命令输入时,使用 `scripts/serial_web_console.ps1`。确认 COM 口已打开并开始记录后,才传输 ROM。
|
4. 默认使用 `scripts/serial_web_console.ps1` 打开可见网页,即使只读查看也使用该控制台;需要用户明确选择的终端只读采集时可使用 `scripts/serial_monitor.ps1`。确认 COM 口已打开并开始记录后,才传输 ROM。
|
||||||
5. 串口网页控制台必须由同一个进程独占 COM 口并完成收、发和日志写入,不能再启动第二个采集或发送进程。服务只绑定 `127.0.0.1`,同时写本任务自己的文本日志和原始字节文件。调试信息不脱敏。
|
5. 串口网页控制台必须由同一个进程独占 COM 口并完成收、发和日志写入,不能再启动第二个采集或发送进程。服务只绑定 `127.0.0.1`,同时写本任务自己的文本日志和原始字节文件。调试信息不脱敏。
|
||||||
6. 不主动读取、复制或上传 SecureCRT 的历史日志。只有本 skill 在本次任务生成的文本和原始字节日志可以默认使用;其他工具日志需用户明确授权。
|
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 文本日志和原始字节日志保持不变。
|
只读串口脚本只负责监测。网页控制台允许用户直接输入命令,页面按 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 分区手工操作
|
## U-Boot 与 A/B 分区手工操作
|
||||||
|
|
||||||
@@ -30,12 +63,36 @@
|
|||||||
|
|
||||||
## Telnet 与交互命令
|
## Telnet 与交互命令
|
||||||
|
|
||||||
1. 先让用户选择串口或 Telnet。串口需要本次确认的 COM 口;Telnet 需要设备完整 SN 和型号,然后广播发现并精确核对 IP,不能沿用历史 IP。
|
1. 新设备调试且连接方式尚未明确时,让用户选择串口或 Telnet;已明确的选择不反复询问。串口需要本任务已确认的 COM 口;Telnet 需要设备完整 SN 和型号,然后广播发现并精确核对 IP,不能沿用历史 IP。
|
||||||
2. Telnet 登录名和密码从当前 Linux 工程的准确配置读取,优先检查 `product.json` 中的用户配置。不要从截图 OCR、相似机型、旧固件或历史记忆猜测,也不要把凭据写入 skill。
|
2. Telnet 登录名和密码从当前 Linux 工程的准确配置读取,优先检查 `product.json` 中的用户配置。不要从截图 OCR、相似机型、旧固件或历史记忆猜测,也不要把凭据写入 skill。
|
||||||
3. 当前配置认证失败或设备版本与配置不一致时再调查历史固件配置。限制失败登录次数;首次明确失败后先停手核实,不连续试猜密码。
|
3. 当前配置认证失败或设备版本与配置不一致时再调查历史固件配置。限制失败登录次数;首次明确失败后先停手核实,不连续试猜密码。
|
||||||
4. 串口与 Telnet 是否共用密码必须由工程配置或用户确认,不能推断。
|
4. 串口与 Telnet 是否共用密码必须由工程配置或用户确认,不能推断。
|
||||||
5. 用户明确要求 `ifconfig` 等命令时,若已有已验证且仍连接的会话,就只发送该命令并实时展示结果;登录验证只执行最少的只读命令,完成后退出。
|
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 已正常重启、网络恢复、版本正确并持续心跳,不应仅凭孤立字样推翻成功结论。
|
日志中的 `error`、`failed`、`timeout` 字样不是自动失败判据。结合发生时间、是否重复、协议终态、设备功能、重启次数以及是否出现 panic、segfault、core dump 或看门狗循环判断。升级停止和启动阶段的一次性模块告警可以记录为警告;若同一 SN 已正常重启、网络恢复、版本正确并持续心跳,不应仅凭孤立字样推翻成功结论。
|
||||||
|
|
||||||
|
本地网卡、DHCP、路由、DNS、协议端口和 `30101` 都正常时,可以把“本地网络与升级闭环”判为通过。云平台激活、Token、绑定、网关发现或业务鉴权错误要单列为云业务警告或失败项;不能用它们推翻已取得的本地网络证据,也不能把本地网络通过扩张为整机云业务全部通过。
|
||||||
|
|
||||||
## 日志快照与远端协查
|
## 日志快照与远端协查
|
||||||
|
|
||||||
持续监测期间不要为了上传而关闭串口。复制本任务生成的活动文本日志和原始字节文件形成稳定快照,记录时间范围并计算 SHA-256,再上传到约定的远端临时目录,例如 `/tmp/<项目>-windows-logs/<时间戳>`。
|
持续监测期间不要为了上传而关闭串口。复制本任务生成的活动文本日志和原始字节文件形成稳定快照,记录时间范围并计算 SHA-256,再上传到约定的远端临时目录,例如 `/tmp/<项目>-windows-logs/<时间戳>`。
|
||||||
@@ -89,12 +148,20 @@
|
|||||||
|
|
||||||
更新串口服务或页面后至少完成以下检查:
|
更新串口服务或页面后至少完成以下检查:
|
||||||
|
|
||||||
|
此处指服务或页面实现变更;仅修改文档时校验技能结构、相对链接和规则一致性,不启动串口生命周期测试,不关闭或重启用户正在使用的控制台。实际设备发送、关闭最后页面等会影响调试的验收,需在用户授权范围内安排;未执行则列为待验收。
|
||||||
|
|
||||||
1. 使用 `serial_web_console.ps1 -ValidateOnly` 验证 C# 服务能够编译且网页资源存在,再运行 Skill 的 `quick_validate.py`。
|
1. 使用 `serial_web_console.ps1 -ValidateOnly` 验证 C# 服务能够编译且网页资源存在,再运行 Skill 的 `quick_validate.py`。
|
||||||
2. 启动前确认目标 COM 口没有被第二个串口工具占用;启动后核对状态接口报告端口已打开,不能只根据 PowerShell 进程存在作结论。
|
2. 启动前确认目标 COM 口没有被第二个串口工具占用;启动后核对状态接口报告端口已打开,不能只根据 PowerShell 进程存在作结论。
|
||||||
3. 设备有输出时核对接收字节数和文本日志文件大小确实增长;设备静默时明确报告“尚未收到数据”,不能把空白页面说成实时采集正常。
|
3. 设备有输出时核对接收字节数和文本日志文件大小确实增长;设备静默时明确报告“尚未收到数据”,不能把空白页面说成实时采集正常。
|
||||||
4. 发送普通文本后,核对设备响应及对应 `[TX:命令]`;命令框为空时发送 Enter,日志必须出现 `[TX:命令] <CR>`。
|
4. 发送普通文本后,核对设备响应及对应 `[TX:命令]`;命令框为空时发送 Enter,日志必须出现 `[TX:命令] <CR>`。
|
||||||
5. 首次打开或刷新页面时应显示最新日志;勾选自动滚动后立即回到底部并持续跟随,用户向上查看历史时页面不能强制抢回滚动位置。
|
5. 首次打开或刷新页面时应显示最新日志;无日志选区时勾选自动滚动立即回到底部并持续跟随,用户向上查看历史时页面不能强制抢回滚动位置。有选区时以选区保护优先。
|
||||||
6. 核对 Enter 发送、Shift+Enter 换行、上下键隐式历史、Esc 清空、输入法候选确认不误发、长按 Enter 不连续空发,以及请求进行中不重复提交。
|
6. 核对 Enter 发送、Shift+Enter 换行、上下键隐式历史、Esc 清空、输入法候选确认不误发、长按 Enter 不连续空发,以及请求进行中不重复提交。
|
||||||
7. 出现 `Password:` 后输入框应自动遮挡,密码不加入页面历史;按当前调试策略,TX 文本日志仍保存实际发送内容。
|
7. 出现 `Password:` 后输入框应自动遮挡,密码不加入页面历史;按当前调试策略,TX 文本日志仍保存实际发送内容。
|
||||||
8. 打开两个页面时关闭其中一个,服务应继续运行;关闭最后一个页面后,正常离页约 `2` 秒、未触发离页事件时最迟约 `5` 秒,状态接口应离线、宿主进程应退出,日志应写入结束标记且 COM 口应释放。刷新页面不能误停服务。
|
8. 打开两个页面时关闭其中一个,服务应继续运行;关闭最后一个页面后,正常离页约 `2` 秒、未触发离页事件时最迟约 `5` 秒,状态接口应离线、宿主进程应退出,日志应写入结束标记且 COM 口应释放。刷新页面不能误停服务。
|
||||||
9. 自动停止后核对文本日志和原始字节文件仍位于原路径且未被截断;再次启动服务并确认能够重新打开同一 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
@@ -29,6 +29,7 @@ namespace EmbeddedAppPcDebug
|
|||||||
private readonly object _logLock = new object();
|
private readonly object _logLock = new object();
|
||||||
private readonly object _stateLock = new object();
|
private readonly object _stateLock = new object();
|
||||||
private readonly Dictionary<string, DateTime> _webClients = new Dictionary<string, DateTime>(StringComparer.Ordinal);
|
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 HttpListener _listener;
|
||||||
private FileStream _rawStream;
|
private FileStream _rawStream;
|
||||||
@@ -122,6 +123,8 @@ namespace EmbeddedAppPcDebug
|
|||||||
_rawPath));
|
_rawPath));
|
||||||
|
|
||||||
_listener = new HttpListener();
|
_listener = new HttpListener();
|
||||||
|
// 必须收到页面连接断开的写异常,不能吞掉后把已关闭的页面永久计为在线。
|
||||||
|
_listener.IgnoreWriteExceptions = false;
|
||||||
_listener.Prefixes.Add(_listenPrefix);
|
_listener.Prefixes.Add(_listenPrefix);
|
||||||
_listener.Start();
|
_listener.Start();
|
||||||
|
|
||||||
@@ -269,6 +272,7 @@ namespace EmbeddedAppPcDebug
|
|||||||
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128) return;
|
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128) return;
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
|
if (_stopping) return;
|
||||||
_webClients[clientId] = DateTime.UtcNow;
|
_webClients[clientId] = DateTime.UtcNow;
|
||||||
_hadWebClient = true;
|
_hadWebClient = true;
|
||||||
_noClientSinceUtc = DateTime.MinValue;
|
_noClientSinceUtc = DateTime.MinValue;
|
||||||
@@ -276,14 +280,76 @@ namespace EmbeddedAppPcDebug
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveWebClient(string clientId)
|
private void RemoveWebClient(string clientId)
|
||||||
|
{
|
||||||
|
RemoveWebClient(clientId, null, "网页主动离页");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveWebClient(string clientId, HttpListenerResponse expectedResponse, string reason)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(clientId)) return;
|
if (string.IsNullOrWhiteSpace(clientId)) return;
|
||||||
|
HttpListenerResponse liveResponse;
|
||||||
|
bool removed;
|
||||||
lock (_stateLock)
|
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)
|
if (_hadWebClient && _webClients.Count == 0 && _noClientSinceUtc == DateTime.MinValue)
|
||||||
_noClientSinceUtc = DateTime.UtcNow;
|
_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()
|
private void WebClientMonitorLoop()
|
||||||
@@ -297,10 +363,14 @@ namespace EmbeddedAppPcDebug
|
|||||||
List<string> expiredClients = new List<string>();
|
List<string> expiredClients = new List<string>();
|
||||||
foreach (KeyValuePair<string, DateTime> item in _webClients)
|
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);
|
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)
|
if (_webClients.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -310,13 +380,14 @@ namespace EmbeddedAppPcDebug
|
|||||||
{
|
{
|
||||||
if (_noClientSinceUtc == DateTime.MinValue) _noClientSinceUtc = now;
|
if (_noClientSinceUtc == DateTime.MinValue) _noClientSinceUtc = now;
|
||||||
shouldStop = (now - _noClientSinceUtc).TotalMilliseconds >= LastClientGraceMilliseconds;
|
shouldStop = (now - _noClientSinceUtc).TotalMilliseconds >= LastClientGraceMilliseconds;
|
||||||
|
// 判定和置位必须在同一把锁内,防止刷新页面刚接管就被旧判定停止。
|
||||||
|
if (shouldStop) _stopping = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldStop)
|
if (shouldStop)
|
||||||
{
|
{
|
||||||
WriteSystemLog("最后一个串口网页已关闭,正在停止日志采集并释放串口。");
|
WriteSystemLog("所有串口网页连接已结束,宽限期内无页面接管;正在停止日志采集并释放串口。");
|
||||||
_stopping = true;
|
|
||||||
try { if (_listener != null) _listener.Stop(); } catch { }
|
try { if (_listener != null) _listener.Stop(); } catch { }
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -377,6 +448,13 @@ namespace EmbeddedAppPcDebug
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method == "GET" && path == "/api/client/live")
|
||||||
|
{
|
||||||
|
ValidateDisconnectRequest(context);
|
||||||
|
SendClientLiveness(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (method == "GET" && path == "/api/log")
|
if (method == "GET" && path == "/api/log")
|
||||||
{
|
{
|
||||||
TouchWebClient(context.Request.QueryString["client"]);
|
TouchWebClient(context.Request.QueryString["client"]);
|
||||||
@@ -442,7 +520,7 @@ namespace EmbeddedAppPcDebug
|
|||||||
|
|
||||||
private void ValidateDisconnectRequest(HttpListenerContext context)
|
private void ValidateDisconnectRequest(HttpListenerContext context)
|
||||||
{
|
{
|
||||||
// sendBeacon 无法自定义请求头,因此仅离页通知接口允许在查询参数中携带会话令牌。
|
// sendBeacon / EventSource 无法自定义请求头,仅页面生命周期接口允许查询令牌。
|
||||||
string token = context.Request.Headers["X-Serial-Token"];
|
string token = context.Request.Headers["X-Serial-Token"];
|
||||||
if (string.IsNullOrWhiteSpace(token)) token = context.Request.QueryString["token"];
|
if (string.IsNullOrWhiteSpace(token)) token = context.Request.QueryString["token"];
|
||||||
if (!string.Equals(token, _sessionToken, StringComparison.Ordinal))
|
if (!string.Equals(token, _sessionToken, StringComparison.Ordinal))
|
||||||
@@ -503,11 +581,13 @@ namespace EmbeddedAppPcDebug
|
|||||||
string lastError;
|
string lastError;
|
||||||
DateTime lastReceive;
|
DateTime lastReceive;
|
||||||
int activeWebClients;
|
int activeWebClients;
|
||||||
|
int liveWebClients;
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
lastError = _lastError;
|
lastError = _lastError;
|
||||||
lastReceive = _lastReceiveUtc;
|
lastReceive = _lastReceiveUtc;
|
||||||
activeWebClients = _webClients.Count;
|
activeWebClients = _webClients.Count;
|
||||||
|
liveWebClients = _liveWebClients.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "{" +
|
return "{" +
|
||||||
@@ -516,6 +596,7 @@ namespace EmbeddedAppPcDebug
|
|||||||
"\"receivedBytes\":" + Interlocked.Read(ref _receivedBytes).ToString(CultureInfo.InvariantCulture) + "," +
|
"\"receivedBytes\":" + Interlocked.Read(ref _receivedBytes).ToString(CultureInfo.InvariantCulture) + "," +
|
||||||
"\"lastReceiveUtc\":\"" + (lastReceive == DateTime.MinValue ? string.Empty : lastReceive.ToString("o", CultureInfo.InvariantCulture)) + "\"," +
|
"\"lastReceiveUtc\":\"" + (lastReceive == DateTime.MinValue ? string.Empty : lastReceive.ToString("o", CultureInfo.InvariantCulture)) + "\"," +
|
||||||
"\"activeWebClients\":" + activeWebClients.ToString(CultureInfo.InvariantCulture) + "," +
|
"\"activeWebClients\":" + activeWebClients.ToString(CultureInfo.InvariantCulture) + "," +
|
||||||
|
"\"liveWebClients\":" + liveWebClients.ToString(CultureInfo.InvariantCulture) + "," +
|
||||||
"\"lastError\":\"" + JsonEscape(lastError) + "\"" +
|
"\"lastError\":\"" + JsonEscape(lastError) + "\"" +
|
||||||
"}";
|
"}";
|
||||||
}
|
}
|
||||||
@@ -581,6 +662,14 @@ namespace EmbeddedAppPcDebug
|
|||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
_stopping = true;
|
_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 (_listener != null) _listener.Stop(); } catch { }
|
||||||
try { if (_serialPort.IsOpen) _serialPort.Close(); } catch { }
|
try { if (_serialPort.IsOpen) _serialPort.Close(); } catch { }
|
||||||
|
|
||||||
|
|||||||
+5
-10
@@ -193,15 +193,14 @@ class SerialWebSession(DeviceSession):
|
|||||||
def __init__(self, base_url: str, expected_port: str, timeout: float) -> None:
|
def __init__(self, base_url: str, expected_port: str, timeout: float) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.client_id = "module-debug-" + uuid.uuid4().hex
|
|
||||||
page = self._request("/").decode("utf-8", errors="replace")
|
page = self._request("/").decode("utf-8", errors="replace")
|
||||||
match = re.search(r'data-token="([^"]+)"', page)
|
match = re.search(r'data-token="([^"]+)"', page)
|
||||||
if not match:
|
if not match:
|
||||||
raise DebugError("串口网页控制台没有返回会话令牌")
|
raise DebugError("串口网页控制台没有返回会话令牌")
|
||||||
self.token = html.unescape(match.group(1))
|
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"):
|
if not status.get("isOpen"):
|
||||||
raise DebugError("串口网页控制台存在,但串口没有打开")
|
raise DebugError("串口网页控制台存在,但串口没有打开")
|
||||||
actual_port = str(status.get("port") or "")
|
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]:
|
def run(self, command: str, timeout: float = 20.0) -> Tuple[int, str]:
|
||||||
marker = "__CODEX_MODULE_RESULT_%s__" % uuid.uuid4().hex
|
marker = "__CODEX_MODULE_RESULT_%s__" % uuid.uuid4().hex
|
||||||
wrapped = "(%s); rc=$?; printf '\\n%s=%%s\\n' \"$rc\"" % (command, marker)
|
wrapped = "(%s); rc=$?; printf '\\n%s=%%s\\n' \"$rc\"" % (command, marker)
|
||||||
start_log = self._request(
|
start_log = self._request("/api/log").decode("utf-8", errors="replace")
|
||||||
"/api/log?client=" + urllib.parse.quote(self.client_id)
|
|
||||||
).decode("utf-8", errors="replace")
|
|
||||||
self._request(
|
self._request(
|
||||||
"/api/send?ending=CR",
|
"/api/send?ending=CR",
|
||||||
data=wrapped.encode("utf-8"),
|
data=wrapped.encode("utf-8"),
|
||||||
@@ -247,9 +244,7 @@ class SerialWebSession(DeviceSession):
|
|||||||
pattern = re.compile(re.escape(marker) + r"=(-?\d+)")
|
pattern = re.compile(re.escape(marker) + r"=(-?\d+)")
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
latest = self._request(
|
latest = self._request("/api/log").decode("utf-8", errors="replace")
|
||||||
"/api/log?client=" + urllib.parse.quote(self.client_id)
|
|
||||||
).decode("utf-8", errors="replace")
|
|
||||||
matches = pattern.findall(latest)
|
matches = pattern.findall(latest)
|
||||||
if matches:
|
if matches:
|
||||||
offset = min(len(start_log), len(latest))
|
offset = min(len(start_log), len(latest))
|
||||||
|
|||||||
+208
@@ -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
@@ -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()
|
||||||
+70
@@ -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())
|
||||||
+84
@@ -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浏览器自动化操作",
|
"name": "playwright浏览器自动化操作",
|
||||||
"version": "20260605",
|
"version": "20260605",
|
||||||
"keySource": "none",
|
"keySource": "none",
|
||||||
"syncedAt": "2026-10-21T16:02:37Z"
|
"syncedAt": "2026-10-22T16:02:41Z"
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "next-skills",
|
"sourceId": "next-skills",
|
||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"commit": "1984f672c01923e26a23ab24063d42a5263b1561",
|
"commit": "443a9e7abf105be7b9e4bfe77200aebb2d46645f",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills",
|
"sourcePath": "skills",
|
||||||
"syncedAt": "2026-10-21T16:00:00Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "ppt-master",
|
"sourceId": "ppt-master",
|
||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "604ecdec8dd6a0405e1abdb49565430902373276",
|
"commit": "622bf0148bca4f697b0184322c7e2aa2a04520a2",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/ppt-master",
|
"sourcePath": "skills/ppt-master",
|
||||||
"syncedAt": "2026-10-21T16:00:00Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
name: ppt-master
|
name: ppt-master
|
||||||
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
|
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
|
||||||
metadata:
|
metadata:
|
||||||
version: "6.7.2"
|
version: "6.7.3"
|
||||||
copyright: "Copyright (c) 2025-2026 Hugo He"
|
copyright: "Copyright (c) 2025-2026 Hugo He"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
official_repository: "https://github.com/hugohe3/ppt-master"
|
official_repository: "https://github.com/hugohe3/ppt-master"
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ from server_common import ( # noqa: E402
|
|||||||
configure_utf8_stdio()
|
configure_utf8_stdio()
|
||||||
|
|
||||||
from annotations import ( # noqa: E402
|
from annotations import ( # noqa: E402
|
||||||
|
SVG_NS,
|
||||||
|
XLINK_NS,
|
||||||
assign_temp_ids,
|
assign_temp_ids,
|
||||||
is_editable_attr,
|
is_editable_attr,
|
||||||
parse_annotations,
|
parse_annotations,
|
||||||
@@ -350,6 +352,19 @@ def _file_digest(path: Path) -> str:
|
|||||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
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:
|
def _atomic_replace(path: Path, write) -> None:
|
||||||
"""Write through ``write(tmp_path)`` and swap the result into ``path``.
|
"""Write through ``write(tmp_path)`` and swap the result into ``path``.
|
||||||
|
|
||||||
@@ -885,7 +900,9 @@ def create_app(
|
|||||||
tag = tag.split('}', 1)[1]
|
tag = tag.split('}', 1)[1]
|
||||||
id_to_tag[eid] = tag
|
id_to_tag[eid] = tag
|
||||||
_normalize_preview_hrefs(root)
|
_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, warnings = _inline_icons(
|
||||||
content,
|
content,
|
||||||
icons_dir,
|
icons_dir,
|
||||||
@@ -1201,6 +1218,7 @@ def create_app(
|
|||||||
annotated_ids = set(anns.keys())
|
annotated_ids = set(anns.keys())
|
||||||
strip_unused_temp_ids(root, annotated_ids)
|
strip_unused_temp_ids(root, annotated_ids)
|
||||||
|
|
||||||
|
_register_svg_namespaces()
|
||||||
try:
|
try:
|
||||||
_atomic_replace(svg_file, lambda tmp: tree.write(
|
_atomic_replace(svg_file, lambda tmp: tree.write(
|
||||||
str(tmp), encoding='UTF-8', xml_declaration=True,
|
str(tmp), encoding='UTF-8', xml_declaration=True,
|
||||||
|
|||||||
+28
@@ -196,6 +196,34 @@ class StagingDraftTests(unittest.TestCase):
|
|||||||
self.assertFalse(self.draft.exists())
|
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):
|
class AnnotationHelperTests(unittest.TestCase):
|
||||||
def test_user_ids_with_the_temp_prefix_are_kept(self) -> None:
|
def test_user_ids_with_the_temp_prefix_are_kept(self) -> None:
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "shadcn",
|
"sourceId": "shadcn",
|
||||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "ba1503c64472b82256d9de95e2e62c5901a38e8b",
|
"commit": "25af02b1b467fcd53b63e0c6d6d324c57c16ef18",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/shadcn",
|
"sourcePath": "skills/shadcn",
|
||||||
"syncedAt": "2026-10-21T06:48:34Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "shuorenhua",
|
"sourceId": "shuorenhua",
|
||||||
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "6c4b9353b87185c26e684c4525be1af47f82fee9",
|
"commit": "e33d5eb6420ba3468043791a57d71c153b0a8691",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": ".",
|
"sourcePath": ".",
|
||||||
"syncedAt": "2026-10-17T16:00:00Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# [UI UX Pro Max](https://uupm.cc)
|
# [UI UX Pro Max](https://uupm.cc)
|
||||||
|
|
||||||
<p align="center">
|
<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.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.ko.md">🇰🇷 한국어</a> |
|
||||||
<a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/blob/main/README.vi.md">🇻🇳 Tiếng Việt</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
|
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
|
### How It Works
|
||||||
|
|
||||||
1. **You ask** - Request any UI/UX task (build, design, create, implement, review, fix, improve)
|
1. **You ask** - Request any UI/UX task (build, design, create, implement, review, fix, improve)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "ui-ux-pro-max",
|
"sourceId": "ui-ux-pro-max",
|
||||||
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "56ea9cf174ed86786187daa9de0e6247cb0747e4",
|
"commit": "623a9a9037d006ea727f8213f084d02a6b6f11b5",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": ".claude/skills/ui-ux-pro-max",
|
"sourcePath": ".claude/skills/ui-ux-pro-max",
|
||||||
"syncedAt": "2026-10-15T03:03:03Z"
|
"syncedAt": "2026-10-22T16:00:00Z"
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user