From 23e3a4910a44ed4641977d0854e4802c9f74feb2 Mon Sep 17 00:00:00 2001 From: zoukai Date: Fri, 11 Sep 2026 17:17:09 +0800 Subject: [PATCH] feat: add embedded app PC debugging skill --- SKILL.md | 66 +++ agents/openai.yaml | 6 + assets/serial-console.html | 420 +++++++++++++++ references/build-workflow.md | 64 +++ references/cross-host-coordination.md | 38 ++ references/device-protocols.md | 108 ++++ references/validation-and-diagnostics.md | 100 ++++ scripts/SerialWebConsoleHost.cs | 631 +++++++++++++++++++++++ scripts/device_upgrade.py | 478 +++++++++++++++++ scripts/serial_monitor.ps1 | 222 ++++++++ scripts/serial_web_console.ps1 | 146 ++++++ scripts/v2_discover.py | 539 +++++++++++++++++++ 12 files changed, 2818 insertions(+) create mode 100644 SKILL.md create mode 100644 agents/openai.yaml create mode 100644 assets/serial-console.html create mode 100644 references/build-workflow.md create mode 100644 references/cross-host-coordination.md create mode 100644 references/device-protocols.md create mode 100644 references/validation-and-diagnostics.md create mode 100644 scripts/SerialWebConsoleHost.cs create mode 100644 scripts/device_upgrade.py create mode 100644 scripts/serial_monitor.ps1 create mode 100644 scripts/serial_web_console.ps1 create mode 100644 scripts/v2_discover.py diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..d830bc7 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,66 @@ +--- +name: embedded-app-pc-debug +description: 用于 Windows 电脑端的嵌入式应用调试,包括固件远程打包、设备发现与身份核对、ROM 升级、串口实时监测、升级后验证和故障诊断。适用于 Eapil/CamTells 工作流,也允许覆盖主机、项目、设备、串口和输出路径;普通代码开发或仅查看已有日志时不要自动触发。 +--- + +# Windows电脑端调试 + +完成从远程工程构建到设备升级验证的闭环,并保留足够证据判断失败发生在哪个阶段。 + +## 按需读取 + +- 涉及远程编译、工程定位、软链接、下载产物时,读取 [references/build-workflow.md](references/build-workflow.md)。 +- 涉及广播搜索、基础信息、EPFP 文件传输或升级时,读取 [references/device-protocols.md](references/device-protocols.md)。 +- 涉及串口监测、升级成功判据或故障定位时,读取 [references/validation-and-diagnostics.md](references/validation-and-diagnostics.md)。 +- 涉及 Linux 打包任务与 Windows 设备任务之间交接时,读取 [references/cross-host-coordination.md](references/cross-host-coordination.md)。 + +只读取当前任务需要的参考文件。不要因为用户仅要求串口查看而加载远程打包流程。 + +## 默认环境 + +- SSH 配置主机:`emb.eapil.com` +- 远程项目根目录:`/CamTells` +- 默认 Public 来源:`~/work/develop/gitcode/EMX2.1` +- 本地归档根目录:`F:\tmp\embedded-app-pc-debug` +- 串口默认值:`115200`、`8N1`、无流控 + +这些都是可覆盖的默认值。不要硬编码项目、SN、IP、COM 口或 ROM 文件。 + +## 工作方式 + +1. 根据用户请求确定需要执行的阶段;不强迫每次都完成整条链路。 +2. 自己调查文件、脚本参数、网卡和设备响应等可查事实,只向用户询问真正需要选择的事项。串口调试必须先取得本次用户确认的 COM 口;Telnet 调试必须先取得完整 SN 和型号。 +3. 将每次完整任务归档到 `<根目录>\<项目>\`,分别保存 `firmware` 和 `logs`。 +4. 搜索可以按型号、IP 或唯一响应列出候选,但这些条件都不能替代完整 SN。Telnet、升级及其他设备写操作必须以用户给出的完整 SN 精确匹配目标,并核对型号;多个候选或同一 SN 出现冲突时停止,不能默认第一台。 +5. 先启动串口监测,再执行设备升级;串口输出同时实时显示并保存日志。需要在网页中查看日志或向设备输入命令时,使用单进程串口网页控制台,避免采集进程和发送进程争用同一个 COM 口。网页首次打开显示最新日志,所有发送内容都写入文本日志,空输入也必须发送真实 `CR`。刷新网页不能中断采集;多个页面共用服务时仅关闭最后一个页面才触发停止。最后页面关闭后,即使处于升级期间,也要在约 `5` 秒内结束采集、保留日志并释放 COM 口;之后必须重新启动控制台才能再次连接。 +6. 在 ROM 传输前集中显示目标 SN、型号、IP、当前版本、ROM 绝对路径、大小、SHA-256、可解析的目标版本、串口状态和所有无法验证的项目。 +7. 只有用户在看到上述信息后明确确认升级,才能传输 ROM、烧录或触发重启。之前对搜索、编译或诊断的授权不能替代这次确认。 +8. 升级失败后先诊断并保留证据,不自动重试;再次升级需要新的明确确认。 + +用户如果只要求“显示串口”或“实时看日志”,仅打开只读串口监测;用户希望在页面持续查看时可以使用本地串口网页控制台,但不自动发送命令、搜索设备、登录、打包、传输文件或升级。 + +## Git 与构建边界 + +- 不执行编译前 `git status` 检查,不因未提交修改阻止打包。 +- 不自动提交、推送、暂存、还原或清理任何仓库修改。 +- 默认执行 `EmxGitInfo` 并保存输出作为版本凭证;失败时记录原因。临时验证包可继续,正式发布是否继续由用户决定。 +- 不使用旧 ROM 冒充本次产物。构建脚本若明确宣告 ROM 目标成功并验证了本次 ROM,之后仅辅助全量镜像步骤失败时,可以交付“仅供局域网升级的 ROM”,但不能声称完整发布包成功。 + +## 工具脚本 + +- `scripts/device_upgrade.py`:V2 `30101` 身份预检与 `EPFP/4001` 升级。缺少 `--execute` 时绝不发送 ROM。 +- `scripts/serial_monitor.ps1`:无第三方依赖的 Windows 串口实时输出、原始字节保存和带时间戳文本日志。 +- `scripts/serial_web_console.ps1`:使用 Windows PowerShell 5.1 启动本地串口网页控制台;由单个进程独占 COM 口并同时负责接收、记录和发送,页面仅监听 `127.0.0.1`,写操作和关闭通知使用随机会话令牌。网页只提供用户主动输入和发送,不启动 U-Boot 持续按键或自动切换 A/B 分区。最后网页关闭后服务退出,不部署常驻管理进程或自动重连。必须用 `powershell.exe` 启动,不要改用 `pwsh.exe`。 +- `scripts/v2_discover.py`:枚举适合广播的 Windows IPv4 网卡,从每个接口的本地 `8951` 向设备 `8941` 发送 V2 广播,并严格筛选完整 SN/型号。 + +运行脚本前先查看 `--help` 或 PowerShell 参数说明。不得绕过脚本的完整 SN、ROM 路径、日志目录和执行开关保护。 + +## 调试数据 + +这是设备调试 skill。用户已选择让终端和任务日志保留原始调试信息,不对 `30102`、串口或构建输出中的密码、密钥、Token 做自动脱敏;但不得把某台设备的真实凭据写入本 skill、脚本默认值或参考文档。 + +## 完成条件 + +不要把连接建立、ROM 发送结束、下载进度达到 100% 或 Flash 计数达到总量单独称为升级成功。至少需要明确的协议成功终态,并继续核对同一 SN 的重启、重发现、网络恢复和目标版本;无法取得某项证据时明确说明,不补造结论。 + +串口网页控制台发生变更时,还要验证服务代码能编译、Skill 结构有效、COM 口由单一进程占用、服务实际连接、接收字节或日志文件持续增长、普通发送与空回车都有 TX 记录,并确认页面首次打开定位到最新日志。还必须完成生命周期验收:刷新不中断、两个页面关闭一个仍运行、关闭最后页面后约 `5` 秒内进程退出且状态接口离线、日志具有结束标记、原日志与原始字节文件仍在原路径,并且下一次服务能重新打开同一 COM 口。仅看到服务进程或空白网页不算验证通过。 diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..d54de00 --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Windows电脑端调试" + short_description: "完成 Windows 电脑端设备发现、固件升级、串口监测与验证" + default_prompt: "使用 $embedded-app-pc-debug 在 Windows 电脑端锁定目标设备,并完成固件升级、串口监测和结果验证。" +policy: + allow_implicit_invocation: true diff --git a/assets/serial-console.html b/assets/serial-console.html new file mode 100644 index 0000000..0670c2b --- /dev/null +++ b/assets/serial-console.html @@ -0,0 +1,420 @@ + + + + + + 串口实时控制台 + + + +
+ 正在连接… + 串口状态读取中 + +
+ +
正在读取串口日志…
+ +
+
+ 快捷填入: + + + + + + + +
+ +
+ COM > + + +
+
+ + + + diff --git a/references/build-workflow.md b/references/build-workflow.md new file mode 100644 index 0000000..1092024 --- /dev/null +++ b/references/build-workflow.md @@ -0,0 +1,64 @@ +# 远程打包与产物归档 + +仅在任务涉及编译新固件或下载远程产物时读取本文件。 + +## 默认值与覆盖 + +| 项目 | 默认值 | +|---|---| +| SSH Host | `emb.eapil.com` | +| 工程根目录 | `/CamTells` | +| Public 来源 | `~/work/develop/gitcode/EMX2.1` | +| 本地归档根目录 | `F:\tmp\embedded-app-pc-debug` | + +用户提供的值优先。凭据只使用现有 SSH 配置、密钥代理或交互式认证,不把密码、私钥和 Token 保存到 skill 或任务日志。 + +## 工程定位 + +1. 先用现有 SSH/VS Code 配置解析 Host,不猜测用户名、端口或私钥路径。 +2. 将用户给出的项目名视为 `/CamTells` 下的相对路径并精确匹配。 +3. 如果有多个候选,列出远程绝对路径等待选择;不要凭目录修改时间挑选。 +4. 确认工程目录包含待用脚本,再确定真实命令行参数。 + +## 构建顺序 + +1. 在当前工程目录执行 `EmxGitInfo`,将标准输出、标准错误和退出码保存为 `logs/EmxGitInfo.txt`。不先运行 Git 工作区检查。 +2. 查看 `scripts/product.py`、`release.py` 的帮助、源码入口或项目说明,确认参数和要求的工作目录。不同工程的参数及启动目录可能不同,不能照搬其他项目命令;脚本位于 `scripts` 不代表应从工程根目录启动,也不代表一定应从 `scripts` 启动。 +3. 只有实际缺少 `Public`、第三方库映射或脚本明确报资源缺失时,才检查并运行 `softLink.py`。创建前核对目标与默认来源 `~/work/develop/gitcode/EMX2.1`,避免覆盖已有有效链接。 +4. 先运行 `product.py` 构建环境包,成功后运行 `release.py` 编译项目。 +5. 每一步记录:工作目录、完整命令、开始/结束时间、退出码和关键输出。 +6. 尊重构建脚本内部的校验与重建循环。脚本因随机命中、签名或封装检查而自动重建时,让脚本自行完成;不要在外层重复启动、重复改版本或把一次内部重建误报为多个发布版本。 +7. 构建失败时不下载旧产物。若本次 ROM 已由脚本明确生成、校验通过,而随后仅全量烧录镜像或其他辅助目标失败,应分开报告:允许交付已验证 ROM 用于局域网升级,但完整发布包仍判为失败或不完整。 + +## 打包任务交付清单 + +Linux 打包任务应一次性交付以下事实,供 Windows 设备任务直接使用: + +- 工程绝对路径、实际运行目录和构建命令; +- ROM 与 `devInfo.json` 的远程绝对路径; +- ROM 大小、MD5、SHA-256、目标内外层版本; +- 构建记录、警告以及哪些发布目标成功或失败; +- 产物是否已下载或排队到 Windows 任务。 + +Windows 任务已取得内容完整且自洽的交付清单后,不重复 SSH 到 Linux 重新计算同一组构建事实。只有交付缺字段、相互矛盾、文件校验不一致或用户明确要求复核时,才回到远端检查。 + +## 下载与归档 + +创建: + +```text +F:\tmp\embedded-app-pc-debug\<项目名>\\ +├─ firmware\ +└─ logs\ +``` + +下载前解析远程 ROM 的真实绝对路径。下载后核对交付清单中的远端大小与本地大小,并计算本地 SHA-256;已有远端 SHA-256 时必须比较。多个 ROM 候选时列出文件名、大小、时间和可解析版本;只有唯一候选才可自动选择。 + +建议日志: + +- `build.log` +- `EmxGitInfo.txt` +- `download.log` +- `firmware.sha256` + +不要自动提交、推送、清理或还原远程仓库。 diff --git a/references/cross-host-coordination.md b/references/cross-host-coordination.md new file mode 100644 index 0000000..2dde6ae --- /dev/null +++ b/references/cross-host-coordination.md @@ -0,0 +1,38 @@ +# Linux 打包任务与 Windows 设备任务协作 + +仅在固件由 Linux 任务构建、由 Windows 任务下载并升级设备时读取本文件。 + +## 职责边界 + +- Linux 任务负责工程定位、构建、产物验证、版本与哈希、构建日志和警告。 +- Windows 任务负责接收产物、本地完整性比较、广播发现、完整 SN/型号核对、串口监测、EPFP 升级和升级后验证。 +- 已由上游任务验证并完整交付的事实,下游不重复计算。发现字段缺失、矛盾、哈希不一致或用户要求复核时再回到上游。 + +## 建议交接流程 + +1. Windows 任务通过可用的 Codex 任务工具联系或等待 Linux 任务;等待超时只表示尚未得到新状态,不要重复发送同一请求。 +2. Linux 任务完成后用短消息交付路径、大小、哈希、版本和结论;ROM、`devInfo.json` 与大日志使用 SCP 或共享文件传递。 +3. Windows 任务比较本地文件与交付清单,然后开始设备侧预检。校验一致时不再登录 Linux 重算哈希或重跑构建命令。 +4. 升级出现异常时,Windows 任务上传本次 skill 生成的串口日志快照,并把精确时间线与诊断问题发回 Linux 任务。 +5. Linux 任务回复源码/构建侧分析;Windows 任务负责将分析与设备现场证据合并成最终验证报告。 + +## 工具与降级原则 + +- 优先使用任务列表、读取、消息和等待能力;不同主机或版本的 Codex 工具能力可能不同,先确认工具实际返回结果。 +- 不得把“文件页已打开/已排队”说成“消息已送达”,也不得在读取结果为空时编造对方结论。 +- 读取不到任务正文时,先核对任务 ID 和主机 ID,再查看最新任务状态。只有任务工具确实不能返回内容时,才在已授权主机上读取对应会话记录或请用户转交摘要。 +- 任务 ID、主机 ID、项目路径和远端临时目录都属于本次运行参数,不写成通用固定值。 + +## 最小交接消息 + +```text +项目:<绝对路径> +ROM:<绝对路径> +devInfo:<绝对路径> +大小: +MD5: +SHA-256: +目标版本:inner=<值>,outer=<值> +构建结论: +警告:<无或摘要> +``` diff --git a/references/device-protocols.md b/references/device-protocols.md new file mode 100644 index 0000000..868b639 --- /dev/null +++ b/references/device-protocols.md @@ -0,0 +1,108 @@ +# 设备发现、基础信息与升级协议 + +仅在任务涉及设备网络发现、版本查询或 ROM 传输时读取本文件。优先使用用户提供的最新协议、现有已验证实现和抓包;文档歧义不得伪装成确定事实。 + +## 本机参考资料 + +存在时优先核对: + +- `C:\Users\ZK\Documents\ChatGPT\日常开发\调试资料\设备端协议V2-工厂整合版本.md` +- `C:\Users\ZK\Documents\ChatGPT\日常开发\调试资料\设备端统一配置协议V2.md` +- `C:\Users\ZK\Downloads\设备端TCP文件传输类协议.md` +- `C:\Users\ZK\Downloads\工厂协议V1.md` +- `E:\CodeSpace\git-pro\eapil_qt_player_v2\EpProtocolDemo` + +## V2 协议矩阵 + +| 功能 | 传输与端口 | 帧格式 | +|---|---|---| +| 设备发现请求 | UDP,客户端从本地 `8951` 向设备 `8941` 广播 | `EPBP + uint32长度 + UTF-8 JSON` | +| 设备发现响应 | UDP,客户端 `8951` 接收 | 同上,完整帧在一个数据报内 | +| 统一配置 | TCP,设备 `8942` | `EPTP + uint32长度 + UTF-8 JSON` | +| 固件升级 | TCP,设备 `8943` | `EPFP + uint32命令 + uint32长度 + 二进制数据` | + +多字节整数使用网络字节序。配置 TCP 长度端序在整合文档中没有单独声明,但现有设备和客户端验证采用网络序。 + +## 发现与身份 + +V2 发现请求 JSON: + +```json +{"cmd":60000,"session":12345} +``` + +响应必须回显相同 `cmd/session`。身份字段包括 `sn`、顶层烧录 `mac`、`inner`、`outer`、`hardware` 和 `net`。顶层 `mac` 与 `net.mac` 可能不同。型号判断同时展示: + +- `inner.type` +- `outer.type` +- `hardware.type` + +非关键网络字段允许缺失。完整 SN 是升级身份核对的首要依据。V1 的端口与帧格式必须从 V1 文档或已验证的 `EpProtocolDemo/V1` 获取,不能套用 V2。 + +执行发现时使用 `scripts/v2_discover.py`,并遵守以下规则: + +1. 默认枚举已启用、已连接且具有有效 IPv4 的物理网卡;回环、断开、APIPA、VPN、VMware、Npcap 等接口要列出并注明排除原因。用户明确要求所有活动接口时才加入虚拟接口。 +2. 每个接口分别绑定其本地 IPv4 和客户端端口 `8951`,同时发送有限广播 `255.255.255.255` 和按前缀计算的定向广播。不能把 `/22` 的广播地址按 `/24` 猜测。 +3. 如果 UDP `8951` 被 EapilCameraConfigurer 或其他进程占用,报告占用者,不结束进程;请用户释放端口,或使用已有工具明确支持的搜索结果。 +4. 从临时源端口发包只可作为诊断。设备可能只向 `8951` 回复,因此临时端口无响应不能证明设备离线。 +5. 打印所有合法候选并按完整 SN 精确匹配;不得选择第一台。响应需校验 `EPBP`、长度、`cmd/session`。只有 SN、源 IP、MAC 和型号都一致的重复响应可以合并;同一 SN 对应不同身份信息时报告冲突并禁止自动选择。 +6. 未提供完整 `--expected-sn` 时脚本只是列表模式,即使退出码为 0 也不代表身份核验成功。Telnet、升级及其他设备写操作必须提供完整 SN,并根据任务要求同时传入型号。 + +## 基础信息 + +通过 TCP `8942` 发送 `cmd=30101`。响应必须满足:同步头正确、长度合理、`cmd/session` 匹配且 `code=0`。读取: + +- `sn`、`mac` +- `inner.type/version` +- `outer.type/version` +- `hardware.type/version` + +`hardware.version` 不是固件版本。升级后应根据 ROM 类型比较 `inner.version` 或 `outer.version`。如果无法从 ROM 或构建记录确定应变化的字段,必须明确说明并让用户确认预期。 + +`30102` 可能返回密码和密钥,只在调试任务确实需要时调用。用户选择保留原始调试数据,因此响应可原样写入本次任务日志,但不得写入 skill 本体。 + +## 固件升级 4001 + +请求: + +```text +EPFP ++ uint32 4001 ++ uint32 ROM字节数 ++ ROM原始字节 +``` + +ROM 前没有 MD5 或目标路径业务前缀。不要把普通文件写入 `2001` 的 MD5 机制声称为升级 `4001` 自带校验。 + +状态业务数据为 21 字节: + +```text +uint8 status ++ uint32 sessionId ++ uint32 downloadTotal ++ uint32 downloaded ++ uint32 flashTotal ++ uint32 flashed +``` + +状态:`0` 空闲、`1` 开始、`2` 完成、`3` 失败。`sessionId` 不等同于配置 JSON session,不要求匹配。解析器要兼容完整 `EPFP/cmd/length` 状态帧与设备实测出现的裸 21 字节状态,但完整帧必须校验响应命令为 `4001`。 + +只有 `status=2` 是协议成功终态;下载或 Flash 计数达到总量不能替代它。`status=3` 立即失败。连续 60 秒没有下载或 Flash 进度变化视为停滞,值允许覆盖。没有依据时不自动续传或重试。 + +设备报告的 `downloadTotal`、`flashTotal` 可能与 ROM 文件字节数不同,不能因为这种封装头、元数据或分区差异单独判失败。`status=2` 是协议成功终态;如果此时 `downloaded/flashed` 仍小于设备各自报告的非零总量,记录协议计数异常警告并纳入验证报告,但不自行推翻设备的成功终态。 + +## 升级前保护 + +- ROM 必须是普通文件,大小大于 0 且不超过无符号 32 位长度。 +- 计算并记录 SHA-256;协议本身没有 ROM 签名或来源认证,不能声称 SHA-256 提供设备侧认证。 +- 精确核对设备 SN。 +- 同时展示当前型号和 ROM 目标型号。ROM 元数据无法解析时显示“无法验证型号”,并重新取得用户确认。 +- 多个 ROM 或设备候选时等待选择。搜索端口被占用或没有完成完整 SN 核对时不得升级。 +- 确认后调用 `scripts/device_upgrade.py --execute`,不绕过其保护参数。 + +## 已知协议歧义 + +- 状态章节没有明确说明每次状态是否带 EPFP 外层头,因此客户端兼容完整帧和裸状态。 +- 没有规定分块大小、连接关闭方、断线恢复或统一总超时;使用可配置值并保存最后进度。 +- 没有定义 ROM 与型号的自动匹配机制。 +- TCP 是字节流,必须累积并按长度解析,不能把单次 `recv` 当作完整帧。 diff --git a/references/validation-and-diagnostics.md b/references/validation-and-diagnostics.md new file mode 100644 index 0000000..8865acc --- /dev/null +++ b/references/validation-and-diagnostics.md @@ -0,0 +1,100 @@ +# 串口监测、验证与故障诊断 + +仅在任务涉及串口、设备重启或升级结果判断时读取本文件。 + +## 串口准备 + +1. 每次串口任务先请用户确认当前 COM 口;可以枚举 Windows 串口及描述帮助确认,但不要沿用上一次的端口或自行挑选。 +2. 默认 `115200`、8 数据位、1 停止位、无校验、无流控,允许覆盖。 +3. 打开失败、打开后没有输出、乱码或访问拒绝时立即停手,请用户核对 COM 口、波特率/校验/数据位/停止位/流控、端口是否已释放,必要时重新插拔或更换转接器。不要强制结束 SecureCRT、串口工具或其他进程。 +4. 只读采集使用 `scripts/serial_monitor.ps1`;需要网页实时显示或网页命令输入时,使用 `scripts/serial_web_console.ps1`。确认 COM 口已打开并开始记录后,才传输 ROM。 +5. 串口网页控制台必须由同一个进程独占 COM 口并完成收、发和日志写入,不能再启动第二个采集或发送进程。服务只绑定 `127.0.0.1`,同时写本任务自己的文本日志和原始字节文件。调试信息不脱敏。 +6. 不主动读取、复制或上传 SecureCRT 的历史日志。只有本 skill 在本次任务生成的文本和原始字节日志可以默认使用;其他工具日志需用户明确授权。 + +只读串口脚本只负责监测。网页控制台允许用户直接输入命令,页面按 Enter 后固定追加 `CR` 并立即发送,不做危险命令二次确认。命令框为空时也发送 `CR`,表现为真实回车。服务接口仍支持无结尾、`CR`、`LF`、`CRLF`,供明确的自动化流程调用。AI 仍只能在用户明确要求或确认具体命令后代为发送,不得自行发送登录名、密码、Shell 命令、break、DTR 或 RTS 脉冲。所有写入数据都在文本日志中记录为 `[TX:来源]`,不可见字符显示为 ``、`` 等;RX 文本日志和原始字节日志保持不变。 + +网页输入区保持在日志下方,只保留快捷填入、命令框和发送按钮,不显示行尾选择、独立空回车、历史下拉、清空、密码开关、U-Boot 连续发送按钮及交互状态栏。页面首次打开时默认定位到最新日志;启用自动滚动后持续跟随最新输出,用户手动向上查看旧日志时不抢回位置,再次勾选自动滚动立即回到底部。Enter 发送、Shift+Enter 输入多行、上下方向键浏览当前页面的隐式历史、Esc 清空输入;处理中文输入法组合状态,确认候选时不能误发串口命令,并阻止长按 Enter 连续发送空回车。发送请求未完成时阻止重复提交,成功后立即刷新 TX 日志并保持输入焦点。页面识别 `Password:` 阶段并自动遮挡输入,而且密码不加入页面命令历史。按用户要求,实际发送内容仍会写入 TX 文本日志。 + +每个网页标签维护独立客户端心跳。最后一个页面正常发送关闭通知后等待约 `2` 秒,若没有刷新后的新页面接管,则服务自动结束日志采集、写入结束标记并释放 COM 口;多个标签同时打开时,关闭其中一个不能停止其他页面。页面异常退出或内置浏览器不触发离页通知时,心跳超过 `3` 秒后清理失联页面,再进入 `2` 秒宽限期,因此最后页面关闭后最迟约 `5` 秒退出。此规则同样适用于升级期间:需要全程监测时必须保持至少一个页面打开。服务启动后尚未打开过网页时不要自动退出。 + +关闭后的文本日志和原始字节文件保留在原路径,不自动移动、删除或截断。服务退出后原网址不可用,不部署常驻管理服务,也不在再次访问时自动打开串口;需要继续查看时重新运行启动脚本。关闭通知只接受回环地址请求,并校验本次随机会话令牌。 + +用户只要求实时显示串口时,到此为止:打开终端面板并持续监测,不附带设备搜索、协议登录、文件上传或升级。 + +## U-Boot 与 A/B 分区手工操作 + +1. 串口网页控制台不提供 U-Boot 持续按键、定时发送、提示符自动识别停止或一键切换 A/B 分区;不要通过隐藏接口或后台线程补回这些行为。 +2. 需要在启动阶段输入 `eapil_uboot_key_str` 时,由用户在普通命令框中自行输入和发送;设备无法由电脑控制重启时,可以在控制台已经连接后请用户手动重启。 +3. 进入 U-Boot 后仍由用户按需填入并主动发送命令。常见核对顺序为:`printenv systemSelect`、`setenv systemSelect A` 或 `setenv systemSelect B`、`saveenv`、再次 `printenv systemSelect`、`reset`。快捷按钮只能填入文本,不能自动发送或串联执行。 +4. 分区恢复成功需要看到内核命令行中的 `systemSelect=...`、系统明确报告运行于目标 A/B 分区、核心应用恢复,并且没有 panic、看门狗或循环重启。网络恢复属于后续独立诊断,不作为这一步成功的必要条件。 + +## Telnet 与交互命令 + +1. 先让用户选择串口或 Telnet。串口需要本次确认的 COM 口;Telnet 需要设备完整 SN 和型号,然后广播发现并精确核对 IP,不能沿用历史 IP。 +2. Telnet 登录名和密码从当前 Linux 工程的准确配置读取,优先检查 `product.json` 中的用户配置。不要从截图 OCR、相似机型、旧固件或历史记忆猜测,也不要把凭据写入 skill。 +3. 当前配置认证失败或设备版本与配置不一致时再调查历史固件配置。限制失败登录次数;首次明确失败后先停手核实,不连续试猜密码。 +4. 串口与 Telnet 是否共用密码必须由工程配置或用户确认,不能推断。 +5. 用户明确要求 `ifconfig` 等命令时,若已有已验证且仍连接的会话,就只发送该命令并实时展示结果;登录验证只执行最少的只读命令,完成后退出。 + +## 成功判据 + +完整成功需要组合证据: + +1. EPFP 返回明确 `status=2`。 +2. 串口出现正常校验、烧录、启动过程,没有明确失败终态;如果存在设备已知成功标记,例如 `update successful`,记录其时间。 +3. 设备完成重启。 +4. 广播重新发现同一个完整 SN。 +5. 网络恢复,目标 IP 可达;IP 变化时以同一 SN 为准更新目标。 +6. `30101` 查询成功。 +7. 应变化的 `inner.version` 或 `outer.version` 与目标构建一致。 + +缺少任一证据时不要补造“全部成功”。可以报告“协议升级完成,但尚未验证版本”或“设备已重启,但未收到协议终态”等部分结论。 + +## 失败分类 + +| 阶段 | 典型证据 | 后续检查 | +|---|---|---| +| 串口准备 | 端口不存在、拒绝访问、被占用 | 核对 COM 口和占用程序 | +| 设备发现 | 无响应、候选冲突 | 网卡、V1/V2、广播出口、防火墙、二层网络 | +| 身份预检 | SN 或型号不匹配 | 停止,不传 ROM,重新选择目标/固件 | +| TCP 连接 | 8942/8943 拒绝或超时 | IP、服务状态、设备网络、端口监听 | +| ROM 发送 | 连接断开、发送异常 | 最后发送量、串口错误、设备重启时点 | +| 下载阶段 | downloaded 不增长 | 网络、设备缓存、服务负载 | +| Flash 阶段 | flashed 不增长、status=3 | ROM 兼容性、分区、校验、Flash 错误 | +| 重启阶段 | 协议完成但串口无启动 | 启动加载器、内核、供电、看门狗 | +| 网络恢复 | 串口正常但未重发现 | DHCP/Wi-Fi、网线、IP 变化、服务启动 | +| 版本回查 | 设备在线但版本未变 | 比较字段选错、升级分区未切换、回滚 | + +日志中的 `error`、`failed`、`timeout` 字样不是自动失败判据。结合发生时间、是否重复、协议终态、设备功能、重启次数以及是否出现 panic、segfault、core dump 或看门狗循环判断。升级停止和启动阶段的一次性模块告警可以记录为警告;若同一 SN 已正常重启、网络恢复、版本正确并持续心跳,不应仅凭孤立字样推翻成功结论。 + +## 日志快照与远端协查 + +持续监测期间不要为了上传而关闭串口。复制本任务生成的活动文本日志和原始字节文件形成稳定快照,记录时间范围并计算 SHA-256,再上传到约定的远端临时目录,例如 `/tmp/<项目>-windows-logs/<时间戳>`。 + +请求 Linux 任务协查时附带:完整 SN、型号、IP、COM/波特率、升级前后版本、问题时间窗口、症状和复现步骤、远端日志路径及 SHA-256、希望对方回答的问题。不要在任务消息里粘贴整份大日志。 + +## 超时和重试 + +- 有进度就继续等待,连续 60 秒无下载或烧写变化才判定疑似停滞。 +- `status=3` 立即失败。 +- TCP 断开但未收到 `status=2` 时不能直接成功;结合串口和重启证据报告“协议终态缺失”。 +- 保存最后状态、进度、串口末尾片段和错误时间线。 +- 不自动再次刷机。分析后重新展示预检信息,并等待新的明确确认。 + +## 验证报告 + +`verification.txt` 按以下顺序记录:项目与 ROM、目标设备身份、升级前后版本、协议结果、串口关键事件、网络与身份复查、未完成检查、最终结论。包含 SHA-256、升级前后 IP 和日志路径,不用把全部串口内容复制进摘要。 + +## 串口网页控制台变更验收 + +更新串口服务或页面后至少完成以下检查: + +1. 使用 `serial_web_console.ps1 -ValidateOnly` 验证 C# 服务能够编译且网页资源存在,再运行 Skill 的 `quick_validate.py`。 +2. 启动前确认目标 COM 口没有被第二个串口工具占用;启动后核对状态接口报告端口已打开,不能只根据 PowerShell 进程存在作结论。 +3. 设备有输出时核对接收字节数和文本日志文件大小确实增长;设备静默时明确报告“尚未收到数据”,不能把空白页面说成实时采集正常。 +4. 发送普通文本后,核对设备响应及对应 `[TX:命令]`;命令框为空时发送 Enter,日志必须出现 `[TX:命令] `。 +5. 首次打开或刷新页面时应显示最新日志;勾选自动滚动后立即回到底部并持续跟随,用户向上查看历史时页面不能强制抢回滚动位置。 +6. 核对 Enter 发送、Shift+Enter 换行、上下键隐式历史、Esc 清空、输入法候选确认不误发、长按 Enter 不连续空发,以及请求进行中不重复提交。 +7. 出现 `Password:` 后输入框应自动遮挡,密码不加入页面历史;按当前调试策略,TX 文本日志仍保存实际发送内容。 +8. 打开两个页面时关闭其中一个,服务应继续运行;关闭最后一个页面后,正常离页约 `2` 秒、未触发离页事件时最迟约 `5` 秒,状态接口应离线、宿主进程应退出,日志应写入结束标记且 COM 口应释放。刷新页面不能误停服务。 +9. 自动停止后核对文本日志和原始字节文件仍位于原路径且未被截断;再次启动服务并确认能够重新打开同一 COM 口。升级期间关闭最后页面也按相同规则停止,不能宣称仍在后台监测。 diff --git a/scripts/SerialWebConsoleHost.cs b/scripts/SerialWebConsoleHost.cs new file mode 100644 index 0000000..79fc29f --- /dev/null +++ b/scripts/SerialWebConsoleHost.cs @@ -0,0 +1,631 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Ports; +using System.Net; +using System.Text; +using System.Threading; + +namespace EmbeddedAppPcDebug +{ + /// + /// 在单一进程内独占串口,同时提供只允许本机访问的日志与命令接口。 + /// + public sealed class SerialWebConsoleHost : IDisposable + { + private const int WebClientTimeoutSeconds = 3; + private const int LastClientGraceMilliseconds = 2000; + private readonly SerialPort _serialPort; + private readonly Encoding _encoding; + private readonly Decoder _decoder; + private readonly string _logPath; + private readonly string _rawPath; + private readonly string _htmlTemplate; + private readonly string _listenPrefix; + private readonly string _sessionToken; + private readonly int _readBufferSize; + private readonly object _writeLock = new object(); + private readonly object _logLock = new object(); + private readonly object _stateLock = new object(); + private readonly Dictionary _webClients = new Dictionary(StringComparer.Ordinal); + + private HttpListener _listener; + private FileStream _rawStream; + private StreamWriter _textWriter; + private Thread _receiveThread; + private Thread _httpThread; + private Thread _clientMonitorThread; + private volatile bool _stopping; + private volatile bool _started; + private bool _hadWebClient; + private DateTime _noClientSinceUtc = DateTime.MinValue; + private string _lastError = string.Empty; + private string _pendingLine = string.Empty; + private long _receivedBytes; + private DateTime _lastReceiveUtc = DateTime.MinValue; + + public SerialWebConsoleHost( + string portName, + int baudRate, + int dataBits, + string parity, + string stopBits, + string handshake, + string encodingName, + int readTimeoutMilliseconds, + int readBufferSize, + bool dtrEnable, + bool rtsEnable, + string logPath, + string rawPath, + int webPort, + string htmlPath) + { + if (string.IsNullOrWhiteSpace(portName)) throw new ArgumentException("串口名称不能为空。", "portName"); + if (string.IsNullOrWhiteSpace(logPath)) throw new ArgumentException("文本日志路径不能为空。", "logPath"); + if (string.IsNullOrWhiteSpace(rawPath)) throw new ArgumentException("原始日志路径不能为空。", "rawPath"); + if (string.IsNullOrWhiteSpace(htmlPath) || !File.Exists(htmlPath)) throw new FileNotFoundException("找不到串口控制台页面。", htmlPath); + + _encoding = Encoding.GetEncoding(encodingName); + _decoder = _encoding.GetDecoder(); + _logPath = Path.GetFullPath(logPath); + _rawPath = Path.GetFullPath(rawPath); + _htmlTemplate = File.ReadAllText(htmlPath, new UTF8Encoding(false)); + _listenPrefix = "http://127.0.0.1:" + webPort.ToString(CultureInfo.InvariantCulture) + "/"; + _sessionToken = Guid.NewGuid().ToString("N"); + _readBufferSize = readBufferSize; + + _serialPort = new SerialPort(); + _serialPort.PortName = portName; + _serialPort.BaudRate = baudRate; + _serialPort.DataBits = dataBits; + _serialPort.Parity = (Parity)Enum.Parse(typeof(Parity), parity, true); + _serialPort.StopBits = (StopBits)Enum.Parse(typeof(StopBits), stopBits, true); + _serialPort.Handshake = (Handshake)Enum.Parse(typeof(Handshake), handshake, true); + _serialPort.DtrEnable = dtrEnable; + _serialPort.RtsEnable = rtsEnable; + _serialPort.ReadTimeout = readTimeoutMilliseconds; + _serialPort.ReadBufferSize = Math.Max(4096, readBufferSize); + } + + public bool IsStopping { get { return _stopping; } } + public string Url { get { return _listenPrefix; } } + + public void Start() + { + if (_started) throw new InvalidOperationException("串口网页服务已经启动。"); + + string logDirectory = Path.GetDirectoryName(_logPath); + string rawDirectory = Path.GetDirectoryName(_rawPath); + if (!string.IsNullOrEmpty(logDirectory)) Directory.CreateDirectory(logDirectory); + if (!string.IsNullOrEmpty(rawDirectory)) Directory.CreateDirectory(rawDirectory); + + try + { + FileStream textStream = new FileStream(_logPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + _textWriter = new StreamWriter(textStream, new UTF8Encoding(false)); + _textWriter.AutoFlush = true; + _rawStream = new FileStream(_rawPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + + _serialPort.Open(); + WriteSystemLog(string.Format( + CultureInfo.InvariantCulture, + "已打开 {0},{1} {2}{3}{4},流控 {5};网页控制台:{6};原始数据:{7}", + _serialPort.PortName, + _serialPort.BaudRate, + _serialPort.DataBits, + _serialPort.Parity.ToString().Substring(0, 1), + _serialPort.StopBits == StopBits.One ? "1" : (_serialPort.StopBits == StopBits.Two ? "2" : "1.5"), + _serialPort.Handshake, + _listenPrefix, + _rawPath)); + + _listener = new HttpListener(); + _listener.Prefixes.Add(_listenPrefix); + _listener.Start(); + + _stopping = false; + _started = true; + _receiveThread = StartBackgroundThread(ReceiveLoop, "SerialWebConsole-RX"); + _httpThread = StartBackgroundThread(HttpLoop, "SerialWebConsole-HTTP"); + _clientMonitorThread = StartBackgroundThread(WebClientMonitorLoop, "SerialWebConsole-WebClient"); + } + catch + { + Stop(); + throw; + } + } + + private static Thread StartBackgroundThread(ThreadStart action, string name) + { + Thread thread = new Thread(action); + thread.IsBackground = true; + thread.Name = name; + thread.Start(); + return thread; + } + + private void ReceiveLoop() + { + byte[] byteBuffer = new byte[_readBufferSize]; + char[] charBuffer = new char[_encoding.GetMaxCharCount(_readBufferSize)]; + + while (!_stopping) + { + try + { + int count = _serialPort.Read(byteBuffer, 0, byteBuffer.Length); + if (count <= 0) continue; + + lock (_logLock) + { + _rawStream.Write(byteBuffer, 0, count); + _rawStream.Flush(); + } + + int charCount = _decoder.GetChars(byteBuffer, 0, count, charBuffer, 0, false); + if (charCount > 0) + { + string text = new string(charBuffer, 0, charCount); + Console.Write(text); + WriteReceivedText(text); + } + + Interlocked.Add(ref _receivedBytes, count); + lock (_stateLock) + { + _lastReceiveUtc = DateTime.UtcNow; + } + } + catch (TimeoutException) + { + // 读取超时用于让线程定期检查退出状态,不属于串口错误。 + } + catch (Exception ex) + { + if (!_stopping) + { + SetFatalError("串口读取失败:" + ex.Message); + } + } + } + } + + private void WriteReceivedText(string text) + { + lock (_logLock) + { + _pendingLine += text; + while (true) + { + int newlineIndex = _pendingLine.IndexOf('\n'); + if (newlineIndex < 0) break; + string line = _pendingLine.Substring(0, newlineIndex).TrimEnd('\r'); + _pendingLine = _pendingLine.Substring(newlineIndex + 1); + _textWriter.WriteLine("[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] " + line); + } + } + + } + + private void WriteCommand(string text, string ending) + { + string terminator; + switch ((ending ?? string.Empty).ToUpperInvariant()) + { + case "NONE": terminator = string.Empty; break; + case "CR": terminator = "\r"; break; + case "LF": terminator = "\n"; break; + case "CRLF": terminator = "\r\n"; break; + default: throw new ArgumentException("换行方式必须是 None、CR、LF 或 CRLF。", "ending"); + } + string payload = (text ?? string.Empty) + terminator; + WriteBytes(_encoding.GetBytes(payload)); + WriteTransmitLog(payload, "命令"); + } + + private void WriteTransmitLog(string text, string source) + { + // 将不可见的行尾转成明确标记,空命令回车会显示为 。 + StringBuilder visible = new StringBuilder(); + foreach (char c in text ?? string.Empty) + { + switch (c) + { + case '\r': visible.Append(""); break; + case '\n': visible.Append(""); break; + case '\t': visible.Append(""); break; + default: + if (c < 32) visible.Append("<0x" + ((int)c).ToString("X2", CultureInfo.InvariantCulture) + ">"); + else visible.Append(c); + break; + } + } + if (visible.Length == 0) visible.Append(""); + + string line = "[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] [TX:" + source + "] " + visible; + Console.WriteLine(line); + lock (_logLock) + { + if (_textWriter != null) _textWriter.WriteLine(line); + } + } + + private void WriteBytes(byte[] bytes) + { + if (bytes == null || bytes.Length == 0) return; + lock (_writeLock) + { + if (_stopping || !_serialPort.IsOpen) throw new IOException("串口当前未打开。"); + _serialPort.BaseStream.Write(bytes, 0, bytes.Length); + _serialPort.BaseStream.Flush(); + } + } + + private void TouchWebClient(string clientId) + { + if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128) return; + lock (_stateLock) + { + _webClients[clientId] = DateTime.UtcNow; + _hadWebClient = true; + _noClientSinceUtc = DateTime.MinValue; + } + } + + private void RemoveWebClient(string clientId) + { + if (string.IsNullOrWhiteSpace(clientId)) return; + lock (_stateLock) + { + _webClients.Remove(clientId); + if (_hadWebClient && _webClients.Count == 0 && _noClientSinceUtc == DateTime.MinValue) + _noClientSinceUtc = DateTime.UtcNow; + } + } + + private void WebClientMonitorLoop() + { + while (!_stopping) + { + bool shouldStop = false; + lock (_stateLock) + { + DateTime now = DateTime.UtcNow; + List expiredClients = new List(); + foreach (KeyValuePair item in _webClients) + { + if ((now - item.Value).TotalSeconds >= WebClientTimeoutSeconds) + expiredClients.Add(item.Key); + } + foreach (string clientId in expiredClients) _webClients.Remove(clientId); + + if (_webClients.Count > 0) + { + _noClientSinceUtc = DateTime.MinValue; + } + else if (_hadWebClient) + { + if (_noClientSinceUtc == DateTime.MinValue) _noClientSinceUtc = now; + shouldStop = (now - _noClientSinceUtc).TotalMilliseconds >= LastClientGraceMilliseconds; + } + } + + if (shouldStop) + { + WriteSystemLog("最后一个串口网页已关闭,正在停止日志采集并释放串口。"); + _stopping = true; + try { if (_listener != null) _listener.Stop(); } catch { } + break; + } + Thread.Sleep(250); + } + } + + private void HttpLoop() + { + while (!_stopping) + { + try + { + HttpListenerContext context = _listener.GetContext(); + ThreadPool.QueueUserWorkItem(delegate { HandleHttpRequest(context); }); + } + catch (HttpListenerException) + { + if (!_stopping) SetFatalError("本地网页服务异常停止。"); + } + catch (ObjectDisposedException) + { + // 服务关闭时 GetContext 会退出。 + } + catch (Exception ex) + { + if (!_stopping) SetFatalError("本地网页服务失败:" + ex.Message); + } + } + } + + private void HandleHttpRequest(HttpListenerContext context) + { + try + { + if (context.Request.RemoteEndPoint == null || !IPAddress.IsLoopback(context.Request.RemoteEndPoint.Address)) + { + SendTextResponse(context, 403, "text/plain; charset=utf-8", "只允许本机访问。"); + return; + } + + string path = context.Request.Url == null ? "/" : context.Request.Url.AbsolutePath; + string method = context.Request.HttpMethod.ToUpperInvariant(); + + if (method == "GET" && (path == "/" || path == "/index.html")) + { + string html = _htmlTemplate + .Replace("__SESSION_TOKEN__", _sessionToken) + .Replace("__SERIAL_PORT__", WebUtility.HtmlEncode(_serialPort.PortName)); + SendTextResponse(context, 200, "text/html; charset=utf-8", html); + return; + } + + if (method == "GET" && path == "/api/status") + { + TouchWebClient(context.Request.QueryString["client"]); + SendTextResponse(context, 200, "application/json; charset=utf-8", BuildStatusJson()); + return; + } + + if (method == "GET" && path == "/api/log") + { + TouchWebClient(context.Request.QueryString["client"]); + SendLogTail(context, 2 * 1024 * 1024); + return; + } + + if (method == "GET" && path == "/favicon.ico") + { + context.Response.StatusCode = 204; + context.Response.Close(); + return; + } + + if (method == "POST") + { + if (path == "/api/send") + { + ValidateCommandRequest(context); + string ending = context.Request.QueryString["ending"] ?? "CR"; + string command = ReadRequestBody(context.Request, 16384); + WriteCommand(command, ending); + SendTextResponse(context, 200, "application/json; charset=utf-8", "{\"status\":\"written\"}"); + return; + } + if (path == "/api/client/disconnect") + { + ValidateDisconnectRequest(context); + RemoveWebClient(context.Request.QueryString["client"]); + SendTextResponse(context, 200, "application/json; charset=utf-8", "{\"status\":\"disconnected\"}"); + return; + } + } + + SendTextResponse(context, 404, "text/plain; charset=utf-8", "未找到接口。"); + } + catch (ArgumentException ex) + { + SendTextResponse(context, 400, "application/json; charset=utf-8", "{\"error\":\"" + JsonEscape(ex.Message) + "\"}"); + } + catch (UnauthorizedAccessException ex) + { + SendTextResponse(context, 403, "application/json; charset=utf-8", "{\"error\":\"" + JsonEscape(ex.Message) + "\"}"); + } + catch (Exception ex) + { + lock (_stateLock) _lastError = ex.Message; + SendTextResponse(context, 503, "application/json; charset=utf-8", "{\"error\":\"" + JsonEscape(ex.Message) + "\"}"); + } + } + + private void ValidateCommandRequest(HttpListenerContext context) + { + string token = context.Request.Headers["X-Serial-Token"]; + if (!string.Equals(token, _sessionToken, StringComparison.Ordinal)) + throw new UnauthorizedAccessException("本地会话令牌无效。"); + + string origin = context.Request.Headers["Origin"]; + if (!string.IsNullOrWhiteSpace(origin) && + !string.Equals(origin.TrimEnd('/') + "/", _listenPrefix, StringComparison.OrdinalIgnoreCase)) + throw new UnauthorizedAccessException("请求来源不是当前串口控制台。"); + } + + private void ValidateDisconnectRequest(HttpListenerContext context) + { + // sendBeacon 无法自定义请求头,因此仅离页通知接口允许在查询参数中携带会话令牌。 + string token = context.Request.Headers["X-Serial-Token"]; + if (string.IsNullOrWhiteSpace(token)) token = context.Request.QueryString["token"]; + if (!string.Equals(token, _sessionToken, StringComparison.Ordinal)) + throw new UnauthorizedAccessException("本地会话令牌无效。"); + + string origin = context.Request.Headers["Origin"]; + if (!string.IsNullOrWhiteSpace(origin) && + !string.Equals(origin.TrimEnd('/') + "/", _listenPrefix, StringComparison.OrdinalIgnoreCase)) + throw new UnauthorizedAccessException("请求来源不是当前串口控制台。"); + } + + private static string ReadRequestBody(HttpListenerRequest request, int maxCharacters) + { + Encoding bodyEncoding = request.ContentEncoding ?? Encoding.UTF8; + using (StreamReader reader = new StreamReader(request.InputStream, bodyEncoding, true, 1024, false)) + { + char[] buffer = new char[maxCharacters + 1]; + int total = 0; + while (total < buffer.Length) + { + int count = reader.Read(buffer, total, buffer.Length - total); + if (count <= 0) break; + total += count; + } + if (total > maxCharacters) throw new ArgumentException("单次命令不能超过 16384 个字符。"); + return new string(buffer, 0, total); + } + } + + private void SendLogTail(HttpListenerContext context, int maxBytes) + { + byte[] data; + using (FileStream stream = new FileStream(_logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + long start = Math.Max(0, stream.Length - maxBytes); + stream.Position = start; + data = new byte[stream.Length - start]; + int offset = 0; + while (offset < data.Length) + { + int count = stream.Read(data, offset, data.Length - offset); + if (count <= 0) break; + offset += count; + } + if (offset != data.Length) Array.Resize(ref data, offset); + } + + context.Response.StatusCode = 200; + context.Response.ContentType = "text/plain; charset=utf-8"; + context.Response.Headers["Cache-Control"] = "no-store"; + context.Response.ContentLength64 = data.Length; + context.Response.OutputStream.Write(data, 0, data.Length); + context.Response.Close(); + } + + private string BuildStatusJson() + { + string lastError; + DateTime lastReceive; + int activeWebClients; + lock (_stateLock) + { + lastError = _lastError; + lastReceive = _lastReceiveUtc; + activeWebClients = _webClients.Count; + } + + return "{" + + "\"port\":\"" + JsonEscape(_serialPort.PortName) + "\"," + + "\"isOpen\":" + (_serialPort.IsOpen && !_stopping ? "true" : "false") + "," + + "\"receivedBytes\":" + Interlocked.Read(ref _receivedBytes).ToString(CultureInfo.InvariantCulture) + "," + + "\"lastReceiveUtc\":\"" + (lastReceive == DateTime.MinValue ? string.Empty : lastReceive.ToString("o", CultureInfo.InvariantCulture)) + "\"," + + "\"activeWebClients\":" + activeWebClients.ToString(CultureInfo.InvariantCulture) + "," + + "\"lastError\":\"" + JsonEscape(lastError) + "\"" + + "}"; + } + + private static string JsonEscape(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + StringBuilder builder = new StringBuilder(value.Length + 16); + foreach (char c in value) + { + switch (c) + { + case '\\': builder.Append("\\\\"); break; + case '"': builder.Append("\\\""); break; + case '\r': builder.Append("\\r"); break; + case '\n': builder.Append("\\n"); break; + case '\t': builder.Append("\\t"); break; + default: + if (c < 32) builder.Append("\\u" + ((int)c).ToString("x4", CultureInfo.InvariantCulture)); + else builder.Append(c); + break; + } + } + return builder.ToString(); + } + + private static void SendTextResponse(HttpListenerContext context, int statusCode, string contentType, string text) + { + try + { + byte[] data = new UTF8Encoding(false).GetBytes(text ?? string.Empty); + context.Response.StatusCode = statusCode; + context.Response.ContentType = contentType; + context.Response.Headers["Cache-Control"] = "no-store"; + context.Response.ContentLength64 = data.Length; + context.Response.OutputStream.Write(data, 0, data.Length); + context.Response.Close(); + } + catch + { + try { context.Response.Abort(); } catch { } + } + } + + private void SetFatalError(string message) + { + lock (_stateLock) _lastError = message; + WriteSystemLog(message); + _stopping = true; + try { if (_listener != null) _listener.Stop(); } catch { } + } + + private void WriteSystemLog(string message) + { + string line = "[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] " + message; + Console.WriteLine(line); + lock (_logLock) + { + if (_textWriter != null) _textWriter.WriteLine(line); + } + } + + public void Stop() + { + _stopping = true; + try { if (_listener != null) _listener.Stop(); } catch { } + try { if (_serialPort.IsOpen) _serialPort.Close(); } catch { } + + JoinThread(_httpThread); + JoinThread(_clientMonitorThread); + JoinThread(_receiveThread); + + lock (_logLock) + { + if (_textWriter != null && !string.IsNullOrEmpty(_pendingLine)) + { + _textWriter.WriteLine("[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] " + _pendingLine.TrimEnd('\r')); + _pendingLine = string.Empty; + } + if (_textWriter != null) + { + _textWriter.WriteLine("[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] 串口网页服务结束"); + _textWriter.Dispose(); + _textWriter = null; + } + if (_rawStream != null) + { + _rawStream.Dispose(); + _rawStream = null; + } + } + + if (_listener != null) + { + try { _listener.Close(); } catch { } + _listener = null; + } + _serialPort.Dispose(); + _started = false; + } + + private static void JoinThread(Thread thread) + { + if (thread == null || thread == Thread.CurrentThread) return; + try { thread.Join(1500); } catch { } + } + + public void Dispose() + { + Stop(); + } + } +} diff --git a/scripts/device_upgrade.py b/scripts/device_upgrade.py new file mode 100644 index 0000000..ce20c67 --- /dev/null +++ b/scripts/device_upgrade.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +"""设备 V2 基础信息预检与 EPFP/4001 固件升级工具。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import select +import socket +import stat +import struct +import sys +import time +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + + +INFO_PORT = 8942 +UPGRADE_PORT = 8943 +INFO_COMMAND = 30101 +UPGRADE_COMMAND = 4001 +MAX_JSON_SIZE = 1024 * 1024 +MAX_STATUS_SIZE = 1024 * 1024 +MAX_ROM_SIZE = 0xFFFFFFFF +STATUS_SIZE = 21 + + +def configure_redirected_output() -> None: + """输出被管道捕获时统一使用 UTF-8,避免中文日志乱码。""" + + for stream in (sys.stdout, sys.stderr): + if not stream.isatty() and hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="backslashreplace") + + +configure_redirected_output() + + +class UpgradeError(RuntimeError): + """表示预检、协议或升级过程失败。""" + + +class EventLogger: + """同时向终端和 JSONL 文件写入带时间戳的事件。""" + + def __init__(self, log_dir: Path) -> None: + log_dir.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + self.path = log_dir / ("device-upgrade-" + stamp + ".jsonl") + self._stream = self.path.open("a", encoding="utf-8", buffering=1) + + def close(self) -> None: + self._stream.close() + + def emit(self, event: str, **fields: Any) -> None: + record: Dict[str, Any] = { + "time": time.strftime("%Y-%m-%d %H:%M:%S"), + "event": event, + } + record.update(fields) + line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + print(line, flush=True) + self._stream.write(line + "\n") + + +def recv_exact(sock: socket.socket, size: int) -> bytes: + """读取指定字节数;对端提前关闭时立即失败。""" + + data = bytearray() + while len(data) < size: + chunk = sock.recv(size - len(data)) + if not chunk: + raise UpgradeError("设备在响应完整之前关闭了连接") + data.extend(chunk) + return bytes(data) + + +def query_device_info(host: str, timeout: float, logger: EventLogger) -> Dict[str, Any]: + """通过 EPTP/30101 查询并严格校验设备基础信息。""" + + session = int(time.time() * 1000) & 0xFFFFFFFF + request_object = {"cmd": INFO_COMMAND, "session": session} + request = json.dumps(request_object, separators=(",", ":")).encode("utf-8") + logger.emit("info_request", host=host, port=INFO_PORT, request=request_object) + + with socket.create_connection((host, INFO_PORT), timeout=timeout) as sock: + sock.settimeout(timeout) + sock.sendall(b"EPTP" + struct.pack("!I", len(request)) + request) + header = recv_exact(sock, 8) + if header[:4] != b"EPTP": + raise UpgradeError("基础信息响应同步头不是 EPTP:%r" % (header[:4],)) + payload_size = struct.unpack("!I", header[4:])[0] + if payload_size == 0 or payload_size > MAX_JSON_SIZE: + raise UpgradeError("基础信息响应长度不合理:%d" % payload_size) + payload = recv_exact(sock, payload_size) + + try: + response = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise UpgradeError("基础信息响应不是有效 UTF-8 JSON:%s" % exc) from exc + if not isinstance(response, dict): + raise UpgradeError("基础信息响应 JSON 顶层不是对象") + if response.get("cmd") != INFO_COMMAND: + raise UpgradeError("基础信息响应 cmd 不匹配:%r" % response.get("cmd")) + if response.get("session") != session: + raise UpgradeError( + "基础信息响应 session 不匹配:期望 %d,实际 %r" + % (session, response.get("session")) + ) + if response.get("code") != 0: + raise UpgradeError("基础信息响应 code 非 0:%r" % response.get("code")) + logger.emit("device_info", response=response) + return response + + +def response_data(response: Dict[str, Any]) -> Dict[str, Any]: + """取得响应 data 对象,格式错误时拒绝继续。""" + + data = response.get("data") + if not isinstance(data, dict): + raise UpgradeError("基础信息响应缺少 data 对象") + return data + + +def collect_models(data: Dict[str, Any]) -> List[str]: + """收集 inner、outer 和 hardware 中可用的型号。""" + + models: List[str] = [] + for section_name in ("inner", "outer", "hardware"): + section = data.get(section_name) + if isinstance(section, dict): + value = section.get("type") + if isinstance(value, str) and value and value not in models: + models.append(value) + return models + + +def collect_versions(data: Dict[str, Any]) -> Dict[str, Any]: + """收集各固件分区和硬件版本,便于升级前后比对。""" + + versions: Dict[str, Any] = {} + for section_name in ("inner", "outer", "hardware"): + section = data.get(section_name) + if isinstance(section, dict): + versions[section_name] = section.get("version") + return versions + + +def validate_identity( + response: Dict[str, Any], expected_sn: str, expected_model: Optional[str] +) -> Tuple[Dict[str, Any], List[str]]: + """对完整 SN 做强校验,并按需校验型号。""" + + data = response_data(response) + actual_sn = data.get("sn") + if actual_sn != expected_sn: + raise UpgradeError("设备 SN 不匹配:期望 %s,实际 %r" % (expected_sn, actual_sn)) + models = collect_models(data) + if expected_model and expected_model not in models: + raise UpgradeError( + "设备型号不匹配:期望 %s,设备报告 %s" + % (expected_model, models if models else "无可用型号") + ) + return data, models + + +def infer_rom_version(path: Path) -> Optional[str]: + """仅从路径中识别明确的四段数字版本号,不猜测其他命名。""" + + candidates: List[str] = [] + pattern = re.compile(r"(? Dict[str, Any]: + """确认 ROM 为普通非空文件,并计算 SHA-256。""" + + absolute = path.expanduser().resolve(strict=True) + metadata = os.stat(str(absolute)) + if not stat.S_ISREG(metadata.st_mode): + raise UpgradeError("ROM 不是普通文件:%s" % absolute) + if metadata.st_size <= 0: + raise UpgradeError("ROM 文件为空:%s" % absolute) + if metadata.st_size > MAX_ROM_SIZE: + raise UpgradeError("ROM 大于协议 uint32 长度上限:%d" % metadata.st_size) + + digest = hashlib.sha256() + with absolute.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return { + "path": str(absolute), + "size": metadata.st_size, + "modified": time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(metadata.st_mtime) + ), + "sha256": digest.hexdigest(), + "target_version_from_path": infer_rom_version(absolute), + } + + +def parse_status(payload: bytes) -> Dict[str, int]: + """解析 21 字节升级状态业务数据。""" + + if len(payload) < STATUS_SIZE: + raise UpgradeError("升级状态不足 21 字节:%d" % len(payload)) + status, session, download_total, downloaded, flash_total, flashed = struct.unpack( + "!BIIIII", payload[:STATUS_SIZE] + ) + if status not in (0, 1, 2, 3): + raise UpgradeError("未知升级状态值:%d" % status) + return { + "status": status, + "session": session, + "download_total": download_total, + "downloaded": downloaded, + "flash_total": flash_total, + "flashed": flashed, + } + + +def extract_status_payloads(buffer: bytearray) -> Iterable[Tuple[str, bytes]]: + """从 TCP 字节流中提取完整 EPFP 帧或兼容设备实测的裸状态。""" + + while buffer: + # 同步头被拆包时等待后续数据,避免把半个 EPFP 当裸状态。 + if len(buffer) < 4 and b"EPFP".startswith(bytes(buffer)): + return + + if buffer[:4] == b"EPFP": + if len(buffer) < 12: + return + command, payload_size = struct.unpack("!II", buffer[4:12]) + if command != UPGRADE_COMMAND: + raise UpgradeError("升级状态帧 cmd 不匹配:%d" % command) + if payload_size < STATUS_SIZE or payload_size > MAX_STATUS_SIZE: + raise UpgradeError("升级状态帧长度不合理:%d" % payload_size) + if len(buffer) < 12 + payload_size: + return + payload = bytes(buffer[12 : 12 + payload_size]) + del buffer[: 12 + payload_size] + yield "framed", payload + continue + + if buffer[0] in (0, 1, 2, 3): + # 合法裸状态内部也可能包含字节串 EPFP,不能在其业务数据中误重同步。 + if len(buffer) < STATUS_SIZE: + return + payload = bytes(buffer[:STATUS_SIZE]) + del buffer[:STATUS_SIZE] + yield "raw", payload + continue + + sync_position = buffer.find(b"EPFP") + if sync_position > 0: + # 首字节不是合法 status,且后方存在同步头时,丢弃无效前缀。 + del buffer[:sync_position] + continue + if len(buffer) < STATUS_SIZE: + return + # 保留这 21 字节交给状态解析器报告明确的非法 status。 + payload = bytes(buffer[:STATUS_SIZE]) + del buffer[:STATUS_SIZE] + yield "raw", payload + + +def transfer_rom( + host: str, + rom_path: Path, + rom_size: int, + connect_timeout: float, + stall_timeout: float, + total_timeout: float, + chunk_size: int, + logger: EventLogger, +) -> Dict[str, Any]: + """发送 ROM,并在同一循环中处理回包、停滞和终态。""" + + result: Dict[str, Any] = {"status": None, "bytes_sent": 0} + receive_buffer = bytearray() + pending = bytearray(b"EPFP" + struct.pack("!II", UPGRADE_COMMAND, rom_size)) + started = time.monotonic() + last_progress = started + previous_progress: Optional[Tuple[int, int]] = None + upload_complete = False + + logger.emit( + "upgrade_connect", host=host, port=UPGRADE_PORT, rom_size=rom_size + ) + with socket.create_connection((host, UPGRADE_PORT), timeout=connect_timeout) as sock: + sock.setblocking(False) + with rom_path.open("rb") as rom: + while True: + now = time.monotonic() + if now - started >= total_timeout: + raise UpgradeError("升级超过总超时 %.1f 秒" % total_timeout) + if now - last_progress >= stall_timeout: + raise UpgradeError("设备下载或烧录进度连续 %.1f 秒无变化" % stall_timeout) + + readable, writable, exceptional = select.select( + [sock], [] if upload_complete else [sock], [sock], 0.5 + ) + if exceptional: + raise UpgradeError("升级 TCP 连接出现异常状态") + + if readable: + chunk = sock.recv(4096) + if not chunk: + raise UpgradeError("设备在 status=2 之前关闭升级连接") + logger.emit("upgrade_rx", size=len(chunk), hex=chunk.hex()) + receive_buffer.extend(chunk) + for frame_type, payload in extract_status_payloads(receive_buffer): + status = parse_status(payload) + logger.emit("device_status", frame=frame_type, **status) + progress = (status["downloaded"], status["flashed"]) + if previous_progress is None or progress != previous_progress: + previous_progress = progress + last_progress = time.monotonic() + result.update(status) + if status["status"] == 3: + raise UpgradeError("设备返回升级失败状态 status=3") + if status["status"] == 2: + incomplete_counters = {} + if ( + status["download_total"] > 0 + and status["downloaded"] < status["download_total"] + ): + incomplete_counters["download"] = { + "total": status["download_total"], + "current": status["downloaded"], + } + if ( + status["flash_total"] > 0 + and status["flashed"] < status["flash_total"] + ): + incomplete_counters["flash"] = { + "total": status["flash_total"], + "current": status["flashed"], + } + if incomplete_counters: + logger.emit( + "protocol_warning", + message="status=2,但设备进度计数尚未达到其自身总量", + counters=incomplete_counters, + ) + logger.emit("protocol_success", status=2) + return result + + if writable: + if not pending: + next_chunk = rom.read(chunk_size) + if next_chunk: + pending.extend(next_chunk) + else: + upload_complete = True + logger.emit( + "client_upload_complete", + bytes_sent=result["bytes_sent"], + rom_size=rom_size, + ) + if pending: + sent = sock.send(pending) + if sent <= 0: + raise UpgradeError("升级 TCP 连接无法继续发送") + # 前 12 字节是 EPFP 头,不计入 ROM 上传进度。 + previous_wire_bytes = result.get("wire_bytes_sent", 0) + wire_bytes = previous_wire_bytes + sent + result["wire_bytes_sent"] = wire_bytes + result["bytes_sent"] = max(0, min(rom_size, wire_bytes - 12)) + del pending[:sent] + + +def build_parser() -> argparse.ArgumentParser: + """创建命令行参数解析器。""" + + parser = argparse.ArgumentParser( + description="V2 设备 30101 身份预检与 EPFP/4001 ROM 升级;默认仅预检。" + ) + parser.add_argument("--host", required=True, help="目标设备 IPv4/主机名") + parser.add_argument("--expected-sn", required=True, help="必须精确匹配的完整设备 SN") + parser.add_argument("--expected-model", help="可选,必须出现在设备型号字段中的型号") + parser.add_argument("--rom", required=True, type=Path, help="ROM 文件绝对或相对路径") + parser.add_argument("--log-dir", required=True, type=Path, help="本次任务日志目录") + parser.add_argument( + "--execute", + action="store_true", + help="实际传输 ROM;未指定时只查询身份、校验 ROM 和输出预检信息", + ) + parser.add_argument("--connect-timeout", type=float, default=10.0) + parser.add_argument("--stall-timeout", type=float, default=60.0) + parser.add_argument("--total-timeout", type=float, default=600.0) + parser.add_argument("--chunk-size", type=int, default=256 * 1024) + return parser + + +def validate_numeric_options(args: argparse.Namespace) -> None: + """拒绝会让超时或分块保护失效的参数。""" + + if args.connect_timeout <= 0: + raise UpgradeError("--connect-timeout 必须大于 0") + if args.stall_timeout <= 0: + raise UpgradeError("--stall-timeout 必须大于 0") + if args.total_timeout <= 0: + raise UpgradeError("--total-timeout 必须大于 0") + if args.chunk_size <= 0 or args.chunk_size > 16 * 1024 * 1024: + raise UpgradeError("--chunk-size 必须在 1 到 16777216 之间") + + +def main(argv: Optional[List[str]] = None) -> int: + """执行预检;仅在明确给出 --execute 时进入升级传输。""" + + args = build_parser().parse_args(argv) + logger: Optional[EventLogger] = None + try: + validate_numeric_options(args) + logger = EventLogger(args.log_dir.expanduser().resolve()) + logger.emit("mode", execute=bool(args.execute), log_file=str(logger.path)) + rom = validate_rom(args.rom) + logger.emit("rom", **rom) + response = query_device_info(args.host, args.connect_timeout, logger) + data, models = validate_identity(response, args.expected_sn, args.expected_model) + summary = { + "host": args.host, + "sn": data.get("sn"), + "mac": data.get("mac"), + "models": models, + "versions": collect_versions(data), + "expected_model": args.expected_model, + "rom": rom, + "rom_model_verification": ( + "由调用方提供并已核对设备型号" + if args.expected_model + else "无法从协议或 ROM 自动验证目标型号" + ), + } + logger.emit("preflight", **summary) + + if not args.execute: + logger.emit("preflight_only", message="未指定 --execute,未连接 8943,未发送 ROM") + return 0 + + result = transfer_rom( + host=args.host, + rom_path=Path(rom["path"]), + rom_size=int(rom["size"]), + connect_timeout=args.connect_timeout, + stall_timeout=args.stall_timeout, + total_timeout=args.total_timeout, + chunk_size=args.chunk_size, + logger=logger, + ) + if result.get("status") != 2: + raise UpgradeError("升级没有取得 status=2 成功终态") + logger.emit( + "upgrade_result", + protocol_success=True, + postcheck_required=True, + result=result, + ) + return 0 + except (OSError, ValueError, UpgradeError) as exc: + if logger is not None: + logger.emit("error", type=type(exc).__name__, message=str(exc)) + else: + print("错误:%s" % exc, file=sys.stderr, flush=True) + return 1 + finally: + if logger is not None: + logger.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/serial_monitor.ps1 b/scripts/serial_monitor.ps1 new file mode 100644 index 0000000..9e790dc --- /dev/null +++ b/scripts/serial_monitor.ps1 @@ -0,0 +1,222 @@ +<# +.SYNOPSIS +实时显示并保存设备串口数据。 + +.DESCRIPTION +使用 .NET SerialPort 直接独占打开串口。收到的数据会原样写入二进制文件, +同时解码后实时显示到当前 PowerShell,并按行写入带时间戳的文本日志。 +默认只读取数据,不向设备发送指令。DtrEnable 和 RtsEnable 会改变硬件控制线, +只有用户明确要求时才能启用。 + +.EXAMPLE +.\serial_monitor.ps1 -Port COMx -LogPath F:\tmp\embedded-app-pc-debug\task\logs\serial.log + +.EXAMPLE +.\serial_monitor.ps1 -Port COMx -LogPath .\serial.log -DurationSeconds 60 + +.PARAMETER DtrEnable +改变 DTR 硬件控制线;仅在用户明确要求时使用。 + +.PARAMETER RtsEnable +改变 RTS 硬件控制线;仅在用户明确要求时使用。 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Port, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$LogPath, + + [ValidateRange(1, 4000000)] + [int]$BaudRate = 115200, + + [ValidateRange(5, 8)] + [int]$DataBits = 8, + + [ValidateSet('None', 'Odd', 'Even', 'Mark', 'Space')] + [string]$Parity = 'None', + + [ValidateSet('One', 'OnePointFive', 'Two')] + [string]$StopBits = 'One', + + [ValidateSet('None', 'XOnXOff', 'RequestToSend', 'RequestToSendXOnXOff')] + [string]$Handshake = 'None', + + [string]$EncodingName = 'utf-8', + + [ValidateRange(0, 2147483647)] + [int]$DurationSeconds = 0, + + [ValidateRange(1, 5000)] + [int]$PollMilliseconds = 100, + + [ValidateRange(64, 1048576)] + [int]$BufferSize = 4096, + + [string]$RawPath, + + [switch]$DtrEnable, + + [switch]$RtsEnable +) + +$ErrorActionPreference = 'Stop' +$serialPort = $null +$textWriter = $null +$rawStream = $null +$pendingLine = '' + +function Write-TimestampedLines { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + + [Parameter(Mandatory = $true)] + [System.IO.StreamWriter]$Writer + ) + + $script:pendingLine += $Text + while ($true) { + $newlineIndex = $script:pendingLine.IndexOf("`n", [System.StringComparison]::Ordinal) + if ($newlineIndex -lt 0) { + break + } + $line = $script:pendingLine.Substring(0, $newlineIndex).TrimEnd("`r") + $script:pendingLine = $script:pendingLine.Substring($newlineIndex + 1) + $Writer.WriteLine(('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $line)) + } +} + +try { + $resolvedLogPath = [System.IO.Path]::GetFullPath($LogPath) + if ([string]::IsNullOrWhiteSpace($RawPath)) { + $resolvedRawPath = $resolvedLogPath + '.raw.bin' + } + else { + $resolvedRawPath = [System.IO.Path]::GetFullPath($RawPath) + } + + $logDirectory = [System.IO.Path]::GetDirectoryName($resolvedLogPath) + $rawDirectory = [System.IO.Path]::GetDirectoryName($resolvedRawPath) + if (-not [string]::IsNullOrWhiteSpace($logDirectory)) { + [System.IO.Directory]::CreateDirectory($logDirectory) | Out-Null + } + if (-not [string]::IsNullOrWhiteSpace($rawDirectory)) { + [System.IO.Directory]::CreateDirectory($rawDirectory) | Out-Null + } + + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + $textFileStream = New-Object System.IO.FileStream( + $resolvedLogPath, + [System.IO.FileMode]::Append, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::Read + ) + $textWriter = New-Object System.IO.StreamWriter($textFileStream, $utf8NoBom) + $textWriter.AutoFlush = $true + $rawStream = New-Object System.IO.FileStream( + $resolvedRawPath, + [System.IO.FileMode]::Append, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::Read + ) + + try { + $encoding = [System.Text.Encoding]::GetEncoding($EncodingName) + } + catch { + throw "无法识别编码 '$EncodingName':$($_.Exception.Message)" + } + $decoder = $encoding.GetDecoder() + + $serialPort = New-Object System.IO.Ports.SerialPort + $serialPort.PortName = $Port + $serialPort.BaudRate = $BaudRate + $serialPort.DataBits = $DataBits + $serialPort.Parity = [System.Enum]::Parse([System.IO.Ports.Parity], $Parity) + $serialPort.StopBits = [System.Enum]::Parse([System.IO.Ports.StopBits], $StopBits) + $serialPort.Handshake = [System.Enum]::Parse([System.IO.Ports.Handshake], $Handshake) + $serialPort.DtrEnable = $DtrEnable.IsPresent + $serialPort.RtsEnable = $RtsEnable.IsPresent + $serialPort.ReadTimeout = $PollMilliseconds + $serialPort.ReadBufferSize = [Math]::Max(4096, $BufferSize) + + try { + $serialPort.Open() + } + catch { + throw "无法打开串口 $Port。串口可能不存在或正被其他程序占用:$($_.Exception.Message)" + } + + $startTime = Get-Date + $startMessage = '[{0}] 已打开 {1},{2} {3}{4}{5},流控 {6};原始数据:{7}' -f ` + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), + $Port, + $BaudRate, + $DataBits, + $Parity.Substring(0, 1), + $(if ($StopBits -eq 'One') { '1' } elseif ($StopBits -eq 'Two') { '2' } else { '1.5' }), + $Handshake, + $resolvedRawPath + [Console]::WriteLine($startMessage) + $textWriter.WriteLine($startMessage) + + $byteBuffer = New-Object byte[] $BufferSize + $charBuffer = New-Object char[] ($encoding.GetMaxCharCount($BufferSize)) + + while ($true) { + if ($DurationSeconds -gt 0 -and ((Get-Date) - $startTime).TotalSeconds -ge $DurationSeconds) { + break + } + + try { + $count = $serialPort.Read($byteBuffer, 0, $byteBuffer.Length) + } + catch [System.TimeoutException] { + continue + } + + if ($count -le 0) { + continue + } + $rawStream.Write($byteBuffer, 0, $count) + $rawStream.Flush() + + $charCount = $decoder.GetChars($byteBuffer, 0, $count, $charBuffer, 0, $false) + if ($charCount -gt 0) { + $text = New-Object string($charBuffer, 0, $charCount) + [Console]::Write($text) + Write-TimestampedLines -Text $text -Writer $textWriter + } + } +} +catch { + # 使用字符串连接保留底层异常文本,避免异常内容再次参与格式化。 + $errorMessage = '[' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + '] 串口监测失败:' + [string]$_.Exception.Message + [Console]::Error.WriteLine($errorMessage) + if ($null -ne $textWriter) { + $textWriter.WriteLine($errorMessage) + } + exit 1 +} +finally { + if ($null -ne $textWriter -and -not [string]::IsNullOrEmpty($script:pendingLine)) { + $textWriter.WriteLine(('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $script:pendingLine.TrimEnd("`r"))) + } + if ($null -ne $serialPort) { + if ($serialPort.IsOpen) { + $serialPort.Close() + } + $serialPort.Dispose() + } + if ($null -ne $rawStream) { + $rawStream.Dispose() + } + if ($null -ne $textWriter) { + $textWriter.WriteLine('[{0}] 串口监测结束' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')) + $textWriter.Dispose() + } +} diff --git a/scripts/serial_web_console.ps1 b/scripts/serial_web_console.ps1 new file mode 100644 index 0000000..197b24d --- /dev/null +++ b/scripts/serial_web_console.ps1 @@ -0,0 +1,146 @@ +<# +.SYNOPSIS +启动支持网页命令输入的串口实时控制台。 + +.DESCRIPTION +使用单一进程独占串口,同时提供串口读取、原始日志、时间戳文本日志和本机网页命令接口。 +网页仅监听 127.0.0.1;普通命令支持 None、CR、LF、CRLF 四种结尾。 + +.EXAMPLE +.\serial_web_console.ps1 -Port COM56 -LogPath F:\tmp\serial.log + +.EXAMPLE +.\serial_web_console.ps1 -Port COM56 -LogPath .\serial.log -WebPort 8766 + +.PARAMETER ValidateOnly +只编译并验证程序和页面资源,不打开串口,也不监听网页端口。 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Port, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$LogPath, + + [ValidateRange(1, 4000000)] + [int]$BaudRate = 115200, + + [ValidateRange(5, 8)] + [int]$DataBits = 8, + + [ValidateSet('None', 'Odd', 'Even', 'Mark', 'Space')] + [string]$Parity = 'None', + + [ValidateSet('One', 'OnePointFive', 'Two')] + [string]$StopBits = 'One', + + [ValidateSet('None', 'XOnXOff', 'RequestToSend', 'RequestToSendXOnXOff')] + [string]$Handshake = 'None', + + [string]$EncodingName = 'utf-8', + + [ValidateRange(1, 5000)] + [int]$PollMilliseconds = 100, + + [ValidateRange(64, 1048576)] + [int]$BufferSize = 4096, + + [string]$RawPath, + + [ValidateRange(1024, 65535)] + [int]$WebPort = 8766, + + [string]$HtmlPath, + + [ValidateRange(0, 2147483647)] + [int]$DurationSeconds = 0, + + [switch]$DtrEnable, + + [switch]$RtsEnable, + + [switch]$ValidateOnly +) + +$ErrorActionPreference = 'Stop' +$broker = $null + +try { + if ($PSVersionTable.PSEdition -ne 'Desktop') { + throw '该脚本需要由 Windows PowerShell 5.1 运行。请使用 powershell.exe,而不是 pwsh.exe。' + } + + $sourcePath = Join-Path $PSScriptRoot 'SerialWebConsoleHost.cs' + if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "找不到串口服务源文件:$sourcePath" + } + + if ([string]::IsNullOrWhiteSpace($HtmlPath)) { + $HtmlPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'assets\serial-console.html' + } + $resolvedHtmlPath = [System.IO.Path]::GetFullPath($HtmlPath) + if (-not (Test-Path -LiteralPath $resolvedHtmlPath -PathType Leaf)) { + throw "找不到串口控制台页面:$resolvedHtmlPath" + } + + # Windows PowerShell 5.1 自带完整 .NET Framework,可直接编译 SerialPort 和 HttpListener。 + Add-Type -Path $sourcePath -ErrorAction Stop + + if ($ValidateOnly.IsPresent) { + Write-Output "验证通过:串口服务代码已编译,网页资源存在。" + Write-Output "源文件:$sourcePath" + Write-Output "页面:$resolvedHtmlPath" + return + } + + $resolvedLogPath = [System.IO.Path]::GetFullPath($LogPath) + if ([string]::IsNullOrWhiteSpace($RawPath)) { + $resolvedRawPath = $resolvedLogPath + '.raw.bin' + } + else { + $resolvedRawPath = [System.IO.Path]::GetFullPath($RawPath) + } + + $broker = [EmbeddedAppPcDebug.SerialWebConsoleHost]::new( + $Port, + $BaudRate, + $DataBits, + $Parity, + $StopBits, + $Handshake, + $EncodingName, + $PollMilliseconds, + $BufferSize, + $DtrEnable.IsPresent, + $RtsEnable.IsPresent, + $resolvedLogPath, + $resolvedRawPath, + $WebPort, + $resolvedHtmlPath + ) + + $broker.Start() + $startTime = Get-Date + Write-Output "串口网页控制台已启动:$($broker.Url)" + Write-Output "按 Ctrl+C 可停止串口采集和网页服务。" + + while (-not $broker.IsStopping) { + if ($DurationSeconds -gt 0 -and ((Get-Date) - $startTime).TotalSeconds -ge $DurationSeconds) { + break + } + Start-Sleep -Milliseconds 200 + } +} +catch { + $message = '[' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + '] 串口网页控制台失败:' + [string]$_.Exception.Message + [Console]::Error.WriteLine($message) + exit 1 +} +finally { + if ($null -ne $broker) { + $broker.Dispose() + } +} diff --git a/scripts/v2_discover.py b/scripts/v2_discover.py new file mode 100644 index 0000000..bf59797 --- /dev/null +++ b/scripts/v2_discover.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +"""通过多个 Windows IPv4 接口执行 V2 EPBP 设备发现。""" + +from __future__ import annotations + +import argparse +import errno +import ipaddress +import json +import secrets +import select +import socket +import struct +import subprocess +import sys +import time +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +DEVICE_PORT = 8941 +CLIENT_PORT = 8951 +DISCOVERY_COMMAND = 60000 +MAX_FRAME_SIZE = 1024 * 1024 + + +class DiscoveryError(RuntimeError): + """表示参数、网卡枚举或协议校验失败。""" + + +@dataclass(frozen=True) +class Interface: + """保存一个 IPv4 接口及其筛选信息。""" + + alias: str + description: str + address: ipaddress.IPv4Address + prefix_length: int + hardware: bool + + @property + def network(self) -> ipaddress.IPv4Network: + """返回接口对应的 IPv4 网络。""" + + return ipaddress.ip_network( + "%s/%d" % (self.address, self.prefix_length), strict=False + ) + + +def configure_output() -> None: + """输出被重定向时统一使用 UTF-8。""" + + for stream in (sys.stdout, sys.stderr): + if not stream.isatty() and hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="backslashreplace") + + +def emit(event: str, **fields: Any) -> None: + """输出单行 JSON,便于终端查看和日志保存。""" + + record: Dict[str, Any] = { + "time": time.strftime("%Y-%m-%d %H:%M:%S"), + "event": event, + } + record.update(fields) + print(json.dumps(record, ensure_ascii=False, separators=(",", ":")), flush=True) + + +def parse_bool(value: Any) -> bool: + """兼容 PowerShell JSON 中的布尔值和字符串。""" + + if isinstance(value, bool): + return value + return str(value).strip().lower() == "true" + + +def powershell_interfaces() -> List[Interface]: + """通过 Windows PowerShell 枚举已启用且具有 IPv4 的接口。""" + + command = r""" +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +$OutputEncoding = [Console]::OutputEncoding +$items = Get-NetIPConfiguration | Where-Object { + $_.NetAdapter.Status -eq 'Up' -and $null -ne $_.IPv4Address +} | ForEach-Object { + $cfg = $_ + foreach ($ipv4 in @($cfg.IPv4Address)) { + [PSCustomObject]@{ + Alias = [string]$cfg.InterfaceAlias + Description = [string]$cfg.InterfaceDescription + Address = [string]$ipv4.IPAddress + PrefixLength = [int]$ipv4.PrefixLength + Hardware = [bool]$cfg.NetAdapter.HardwareInterface + } + } +} +@($items) | ConvertTo-Json -Compress +""" + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise DiscoveryError("无法调用 PowerShell 枚举网卡:%s" % exc) from exc + if result.returncode != 0: + raise DiscoveryError("PowerShell 网卡枚举失败:%s" % result.stderr.strip()) + try: + decoded = json.loads(result.stdout or "[]") + except json.JSONDecodeError as exc: + raise DiscoveryError("PowerShell 网卡结果不是有效 JSON:%s" % exc) from exc + if isinstance(decoded, dict): + decoded = [decoded] + + interfaces: List[Interface] = [] + for item in decoded: + if not isinstance(item, dict): + continue + try: + address = ipaddress.IPv4Address(str(item.get("Address", ""))) + prefix_length = int(item.get("PrefixLength")) + ipaddress.ip_network("%s/%d" % (address, prefix_length), strict=False) + except (ipaddress.AddressValueError, ValueError, TypeError): + continue + interfaces.append( + Interface( + alias=str(item.get("Alias") or "未知接口"), + description=str(item.get("Description") or ""), + address=address, + prefix_length=prefix_length, + hardware=parse_bool(item.get("Hardware")), + ) + ) + return interfaces + + +def explicit_interfaces(values: Sequence[str]) -> List[Interface]: + """解析重复传入的 IP/前缀接口参数。""" + + interfaces: List[Interface] = [] + for index, value in enumerate(values, 1): + try: + parsed = ipaddress.ip_interface(value) + except ValueError as exc: + raise DiscoveryError("无效的 --interface %r:%s" % (value, exc)) from exc + if not isinstance(parsed, ipaddress.IPv4Interface): + raise DiscoveryError("--interface 只支持 IPv4:%s" % value) + interfaces.append( + Interface( + alias="手工接口%d" % index, + description="由 --interface 指定", + address=parsed.ip, + prefix_length=parsed.network.prefixlen, + hardware=True, + ) + ) + return interfaces + + +def exclusion_reason(interface: Interface, include_virtual: bool) -> Optional[str]: + """返回接口的排除原因;返回空值表示可用于搜索。""" + + if interface.address.is_loopback: + return "回环地址" + if interface.address.is_link_local: + return "APIPA/链路本地地址" + if interface.address.is_unspecified or interface.address.is_multicast: + return "不是可用单播地址" + if not interface.hardware and not include_virtual: + return "虚拟或 VPN 接口(使用 --include-virtual 可加入)" + return None + + +def make_frame(session: int) -> bytes: + """生成 V2 发现请求帧。""" + + payload = json.dumps( + {"cmd": DISCOVERY_COMMAND, "session": session}, separators=(",", ":") + ).encode("utf-8") + return b"EPBP" + struct.pack("!I", len(payload)) + payload + + +def parse_frame(packet: bytes, expected_session: int) -> Dict[str, Any]: + """严格解析并校验一个完整 EPBP 数据报。""" + + if len(packet) < 8 or packet[:4] != b"EPBP": + raise DiscoveryError("响应同步头不是 EPBP") + payload_size = struct.unpack("!I", packet[4:8])[0] + if payload_size <= 0 or payload_size > MAX_FRAME_SIZE: + raise DiscoveryError("响应长度不合理:%d" % payload_size) + if len(packet) != 8 + payload_size: + raise DiscoveryError( + "响应数据报长度不匹配:声明 %d,实际 %d" % (payload_size, len(packet) - 8) + ) + try: + response = json.loads(packet[8:].decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DiscoveryError("响应不是有效 UTF-8 JSON:%s" % exc) from exc + if not isinstance(response, dict): + raise DiscoveryError("响应 JSON 顶层不是对象") + if response.get("cmd") != DISCOVERY_COMMAND: + raise DiscoveryError("响应 cmd 不匹配:%r" % response.get("cmd")) + if response.get("session") != expected_session: + raise DiscoveryError("响应 session 不匹配:%r" % response.get("session")) + return response + + +def response_data(response: Dict[str, Any]) -> Dict[str, Any]: + """兼容身份位于顶层或 data 对象的设备响应。""" + + data = response.get("data") + return data if isinstance(data, dict) else response + + +def identity(response: Dict[str, Any]) -> Tuple[Optional[str], List[str], Optional[str]]: + """提取完整 SN、型号集合和 MAC。""" + + data = response_data(response) + sn = data.get("sn") if isinstance(data.get("sn"), str) else None + mac = data.get("mac") if isinstance(data.get("mac"), str) else None + models: List[str] = [] + for section_name in ("inner", "outer", "hardware"): + section = data.get(section_name) + if isinstance(section, dict): + model = section.get("type") + if isinstance(model, str) and model and model not in models: + models.append(model) + return sn, models, mac + + +def port_owner(port: int) -> Optional[Dict[str, Any]]: + """在 Windows 上查询 UDP 端口占用者,不结束任何进程。""" + + if sys.platform != "win32": + return None + command = ( + "$e=Get-NetUDPEndpoint -LocalPort %d -ErrorAction SilentlyContinue | " + "Select-Object -First 1; if($null -ne $e){$p=Get-Process -Id $e.OwningProcess " + "-ErrorAction SilentlyContinue;[PSCustomObject]@{PID=$e.OwningProcess;" + "Process=$p.ProcessName;Address=$e.LocalAddress}|ConvertTo-Json -Compress}" % port + ) + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + ) + return json.loads(result.stdout) if result.stdout.strip() else None + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError): + return None + + +def open_sockets( + interfaces: Iterable[Interface], client_port: int +) -> List[Tuple[Interface, socket.socket]]: + """为每个 IPv4 接口创建独立的广播收发套接字。""" + + opened: List[Tuple[Interface, socket.socket]] = [] + for interface in interfaces: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + try: + sock.bind((str(interface.address), client_port)) + except OSError as exc: + sock.close() + owner = port_owner(client_port) if exc.errno in (errno.EACCES, errno.EADDRINUSE, 10013, 10048) else None + emit( + "bind_error", + interface=interface.alias, + address=str(interface.address), + port=client_port, + error=str(exc), + owner=owner, + action="请释放端口;本脚本不会结束占用进程", + ) + continue + sock.setblocking(False) + opened.append((interface, sock)) + emit( + "socket_ready", + interface=interface.alias, + address=str(interface.address), + port=client_port, + ) + return opened + + +def discover( + opened: Sequence[Tuple[Interface, socket.socket]], + device_port: int, + timeout: float, + session: int, +) -> List[Dict[str, Any]]: + """向每个接口发送两类广播并收集合法响应。""" + + frame = make_frame(session) + socket_map = {sock: interface for interface, sock in opened} + for interface, sock in opened: + destinations = ["255.255.255.255", str(interface.network.broadcast_address)] + for destination in dict.fromkeys(destinations): + try: + sock.sendto(frame, (destination, device_port)) + emit( + "broadcast_sent", + interface=interface.alias, + source=str(interface.address), + destination=destination, + device_port=device_port, + session=session, + ) + except OSError as exc: + emit( + "send_error", + interface=interface.alias, + destination=destination, + error=str(exc), + ) + + deadline = time.monotonic() + timeout + found: List[Dict[str, Any]] = [] + while socket_map: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + readable, _, _ = select.select(list(socket_map), [], [], remaining) + if not readable: + break + for sock in readable: + interface = socket_map[sock] + try: + packet, peer = sock.recvfrom(MAX_FRAME_SIZE + 9) + response = parse_frame(packet, session) + except (OSError, DiscoveryError) as exc: + emit( + "invalid_response", + interface=interface.alias, + error=str(exc), + ) + continue + sn, models, mac = identity(response) + candidate = { + "source_ip": peer[0], + "source_port": peer[1], + "received_on": str(interface.address), + "interface": interface.alias, + "sn": sn, + "models": models, + "mac": mac, + "response": response, + } + found.append(candidate) + emit("candidate", **candidate) + return found + + +def deduplicate(candidates: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]: + """只合并 SN、源地址、MAC 和型号完全一致的重复响应。""" + + unique: Dict[Tuple[str, ...], Dict[str, Any]] = {} + for candidate in candidates: + sn = candidate.get("sn") + key = ( + "identity", + str(sn or ""), + str(candidate.get("source_ip") or ""), + str(candidate.get("mac") or ""), + json.dumps(candidate.get("models", []), ensure_ascii=False, separators=(",", ":")), + ) + unique.setdefault(key, candidate) + return list(unique.values()) + + +def find_identity_conflicts(candidates: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]: + """找出同一完整 SN 对应多个网络或型号身份的冲突。""" + + grouped: Dict[str, List[Dict[str, Any]]] = {} + for candidate in candidates: + sn = candidate.get("sn") + if isinstance(sn, str) and sn: + grouped.setdefault(sn, []).append(candidate) + return [ + {"sn": sn, "candidates": items} + for sn, items in grouped.items() + if len(items) > 1 + ] + + +def select_expected( + candidates: Sequence[Dict[str, Any]], expected_sn: Optional[str], expected_model: Optional[str] +) -> List[Dict[str, Any]]: + """按完整 SN 和型号筛选候选。""" + + selected = list(candidates) + if expected_sn: + selected = [item for item in selected if item.get("sn") == expected_sn] + if expected_model: + selected = [item for item in selected if expected_model in item.get("models", [])] + return selected + + +def build_parser() -> argparse.ArgumentParser: + """创建命令行参数解析器。""" + + parser = argparse.ArgumentParser( + description="从每个活动 IPv4 接口的本地 8951 端口执行 V2 EPBP 广播搜索。" + ) + parser.add_argument( + "--interface", + action="append", + default=[], + metavar="IP/PREFIX", + help="手工指定接口,可重复;给出后不自动枚举,格式为本机IPv4/前缀长度", + ) + parser.add_argument( + "--include-virtual", + action="store_true", + help="自动枚举时同时使用活动的虚拟、VPN 等非物理接口", + ) + parser.add_argument("--expected-sn", help="期望的完整设备 SN,按完全相等匹配") + parser.add_argument("--expected-model", help="期望型号,匹配 inner/outer/hardware.type") + parser.add_argument("--timeout", type=float, default=3.0, help="接收等待秒数") + parser.add_argument("--client-port", type=int, default=CLIENT_PORT) + parser.add_argument("--device-port", type=int, default=DEVICE_PORT) + return parser + + +def validate_options(args: argparse.Namespace) -> None: + """拒绝无效端口和等待时间。""" + + if args.timeout <= 0 or args.timeout > 300: + raise DiscoveryError("--timeout 必须在 0 到 300 秒之间") + for name in ("client_port", "device_port"): + value = getattr(args, name) + if value <= 0 or value > 65535: + raise DiscoveryError("--%s 必须在 1 到 65535 之间" % name.replace("_", "-")) + + +def main(argv: Optional[List[str]] = None) -> int: + """执行网卡筛选、广播搜索和唯一身份匹配。""" + + opened: List[Tuple[Interface, socket.socket]] = [] + try: + args = build_parser().parse_args(argv) + validate_options(args) + if args.client_port != CLIENT_PORT: + emit( + "diagnostic_port", + client_port=args.client_port, + warning="设备可能只回复到 8951;非标准源端口无响应不能证明设备离线", + ) + interfaces = ( + explicit_interfaces(args.interface) if args.interface else powershell_interfaces() + ) + selected_interfaces: List[Interface] = [] + for interface in interfaces: + reason = exclusion_reason(interface, args.include_virtual) + emit( + "interface", + alias=interface.alias, + description=interface.description, + address=str(interface.address), + prefix_length=interface.prefix_length, + directed_broadcast=str(interface.network.broadcast_address), + hardware=interface.hardware, + selected=reason is None, + exclusion_reason=reason, + ) + if reason is None: + selected_interfaces.append(interface) + if not selected_interfaces: + raise DiscoveryError("没有可用于广播的 IPv4 接口") + + opened = open_sockets(selected_interfaces, args.client_port) + if not opened: + raise DiscoveryError( + "所有接口都无法绑定本地端口 %d;请检查端口占用" % args.client_port + ) + session = secrets.randbelow(0xFFFFFFFF) + 1 + candidates = deduplicate( + discover(opened, args.device_port, args.timeout, session) + ) + conflicts = find_identity_conflicts(candidates) + if conflicts: + emit( + "identity_conflict", + message="同一完整 SN 对应不同 IP、MAC 或型号,禁止自动选择设备", + conflicts=conflicts, + ) + return 2 + matches = select_expected(candidates, args.expected_sn, args.expected_model) + identity_verified = bool(args.expected_sn and len(matches) == 1) + emit( + "summary", + mode="identity_check" if args.expected_sn else "list_only", + identity_verified=identity_verified, + candidate_count=len(candidates), + candidates=candidates, + expected_sn=args.expected_sn, + expected_model=args.expected_model, + match_count=len(matches), + matches=matches, + ) + if not candidates: + emit( + "not_found", + message=( + "未收到合法响应;若使用的不是客户端端口 8951,此结果不能证明设备离线" + ), + ) + return 2 + if args.expected_sn: + if len(matches) != 1: + emit("identity_error", message="期望身份没有唯一匹配,禁止自动选择设备") + return 2 + return 0 + except (OSError, ValueError, DiscoveryError) as exc: + emit("error", type=type(exc).__name__, message=str(exc)) + return 1 + finally: + for _, sock in opened: + sock.close() + + +configure_output() + + +if __name__ == "__main__": + raise SystemExit(main())