From d50be29246f1ca6df8d965a400ca151d6f9f7beb Mon Sep 17 00:00:00 2001 From: zoukai Date: Wed, 21 Oct 2026 10:16:52 +0800 Subject: [PATCH] Add V1 protocol discovery and upgrade support --- SKILL.md | 6 ++- references/device-protocols.md | 37 +++++++++++++++ scripts/device_upgrade.py | 49 ++++++++++++++++++-- scripts/v1_command.py | 70 ++++++++++++++++++++++++++++ scripts/v1_discover.py | 84 ++++++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 scripts/v1_command.py create mode 100644 scripts/v1_discover.py diff --git a/SKILL.md b/SKILL.md index 510aa80..93d18f0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -11,7 +11,7 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编 - 涉及远程编译、工程定位、软链接、下载产物时,读取 [references/build-workflow.md](references/build-workflow.md)。 - 涉及 NFS 模块部署、`DxMain`、开机启动脚本、工厂模式或加密 param 时,读取 [references/application-debugging.md](references/application-debugging.md)。 -- 涉及广播搜索、基础信息、EPFP 文件传输或升级时,读取 [references/device-protocols.md](references/device-protocols.md)。 +- 涉及 V1/V2 广播搜索、工厂/产测命令、统一配置、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)。 @@ -60,7 +60,9 @@ description: 用于 Windows 电脑端的嵌入式应用调试,包括远程编 ## 工具脚本 -- `scripts/device_upgrade.py`:V2 `30101` 身份预检与 `EPFP/4001` 升级。缺少 `--execute` 时绝不发送 ROM。 +- `scripts/v1_discover.py`:V1 UDP `4942` 搜索(`0x5A0EFF00/01`);可用 `--interface` 指定本机 IPv4。端口冲突时停止,不抢占进程。 +- `scripts/v1_command.py`:V1 TCP `4941` 通用 CMD/Data 请求器;默认仅预检,所有命令只有显式 `--execute` 才发送。它不解析每条命令的业务语义,副作用命令必须先查对应协议并核对设备身份。 +- `scripts/device_upgrade.py`:默认使用 V2 `30101` 身份预检;可用 `--protocol v1` 通过 V1 `0x5A0E2104` 核验后执行 EPFP/4001。V1 与 EPFP 的兼容性须由目标固件资料确认。缺少 `--execute` 时绝不连接升级端口或发送 ROM。 - `scripts/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/型号。 diff --git a/references/device-protocols.md b/references/device-protocols.md index 868b639..02c3c26 100644 --- a/references/device-protocols.md +++ b/references/device-protocols.md @@ -12,6 +12,43 @@ - `C:\Users\ZK\Downloads\工厂协议V1.md` - `E:\CodeSpace\git-pro\eapil_qt_player_v2\EpProtocolDemo` +## 协议资料总览 + +本技能覆盖的协议资料不止设备搜索和升级。按当前本机资料,范围包括: + +| 协议族 | 资料范围 | 本技能的使用方式 | +|---|---|---| +| V1 工厂协议 | 66 条工厂/产测命令;UDP 搜索与 TCP 控制 | `scripts/v1_discover.py` 搜索;`scripts/v1_command.py` 对文档中任意 CMD 发送 JSON 请求 | +| V2 广播发现 | EPBC/EPBD、UDP 8941/8951、cmd 60000 | `scripts/v2_discover.py` | +| V2 统一配置 | cmd 30101 起,覆盖基本信息、网络、音视频、音频、设备操作、SD 卡、夜视、时间、OSD、IO 等文档章节 | TCP 8942;当前脚本仅做身份查询,其他命令按文档单独实现/调用;不得把语义协议误当作固定传输协议 | +| V2 产测 | cmd 1–26,LED、按键、老化、带宽、PCBA、屏幕、MIC、SD、芯片、镜头等 | 文档定义命令语义;需使用匹配固件提供的传输封装 | +| TCP 文件传输 | EPFP,2001/2002/3001/4001,端口 8943 | `scripts/device_upgrade.py` 实现 4001 固件升级;其余命令保留文档语义,不由该脚本执行 | +| TCP 音视频播放 | 端口 8944,cmd 0x01–0x05 | 长连接心跳、解码器列表、播放控制及媒体帧;不是设备发现或升级通道 | +| V2 工厂整合协议 | 工厂扩展命令及设备信息接口 | 按资料版本与设备支持情况使用;与 V1 工厂命令空间不能混用 | + +“覆盖”指协议字段、命令范围、边界和现有脚本能力都在参考文档中可查;不代表每种协议都有通用执行器,也不代表所有固件都启用了每条命令。新增写操作前须逐条查对应协议的请求、响应和副作用。 + +## V1 工厂协议 + +V1 使用独立于 V2 的报文,不带 `EPBP`/`EPTP`/`EPFP` 同步字: + +```text +uint32 CMD (network byte order) ++ uint32 Data length (network byte order) ++ Data bytes (normally UTF-8 JSON; optional) +``` + +| 功能 | 传输 | 端口 | 命令 | +|---|---|---:|---| +| 设备发现请求/响应 | UDP 广播 | 设备 4942 | `0x5A0EFF00` / `0x5A0EFF01` | +| 工厂/产测控制 | TCP | 4941 | 按 V1 文档中各接口的 CMD | + +搜索请求 Data 为 `{"session":<随机整数>}`。只接受 `0x5A0EFF01` 且回显本次 session 的响应。设备响应 JSON 保留原始字段;协议版本之间字段可能不同,型号、SN、IP、MAC 等信息必须根据实际响应展示,缺失即标为不可用。多网卡或设备搜索工具已占用 UDP 4942 时,先确定本机接口/端口状态;不结束占用进程。V1 搜索用 `scripts/v1_discover.py`,对单个接口显式指定 `--interface <本机IPv4>`。 + +`scripts/v1_command.py` 是通用 TCP 请求器:指定 IP、CMD 和 JSON Data;默认仅显示预检,不建立连接。只有显式 `--execute` 才发送。它校验 TCP 响应头、长度和响应 CMD,但不解释各命令的业务成功字段、不判断写操作安全性,也不执行设备身份核验。因此必须先将发现的设备与完整 SN/型号人工核对;对写配置、复位、格式化、烧号或其他有副作用命令,先核实请求字段及后果并取得本次操作确认。不得用该通用脚本发送 ROM。 + +V1 工厂协议文档列出的 66 条命令及字段定义是命令语义来源。传输层客户端可承载这些 CMD,但各命令是否被具体型号/固件实现,仍需用对应版本文档或设备响应确认。V1 示例工程只实现了搜索和前五个 TCP 示例命令,不代表其它 V1 命令已在设备上验证。 + ## V2 协议矩阵 | 功能 | 传输与端口 | 帧格式 | diff --git a/scripts/device_upgrade.py b/scripts/device_upgrade.py index ce20c67..c7a5b7d 100644 --- a/scripts/device_upgrade.py +++ b/scripts/device_upgrade.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""设备 V2 基础信息预检与 EPFP/4001 固件升级工具。""" +"""V1/V2 身份预检与 EPFP/4001 固件升级工具。""" from __future__ import annotations @@ -21,6 +21,8 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple INFO_PORT = 8942 UPGRADE_PORT = 8943 INFO_COMMAND = 30101 +V1_INFO_PORT = 4941 +V1_INFO_COMMAND = 0x5A0E2104 UPGRADE_COMMAND = 4001 MAX_JSON_SIZE = 1024 * 1024 MAX_STATUS_SIZE = 1024 * 1024 @@ -116,6 +118,30 @@ def query_device_info(host: str, timeout: float, logger: EventLogger) -> Dict[st return response +def query_v1_device_info(host: str, timeout: float, logger: EventLogger) -> Dict[str, Any]: + """通过 V1 工厂 TCP/2104 获取设备身份与固件字段。""" + logger.emit("v1_info_request", host=host, port=V1_INFO_PORT, + command="0x%08X" % V1_INFO_COMMAND) + with socket.create_connection((host, V1_INFO_PORT), timeout=timeout) as sock: + sock.settimeout(timeout) + sock.sendall(struct.pack("!II", V1_INFO_COMMAND, 0)) + header = recv_exact(sock, 8) + command, size = struct.unpack("!II", header) + if command != V1_INFO_COMMAND or size > MAX_JSON_SIZE: + raise UpgradeError("V1 固件信息响应 CMD 或长度异常") + payload = recv_exact(sock, size) + try: + response = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise UpgradeError("V1 固件信息响应不是有效 JSON:%s" % exc) from exc + if not isinstance(response, dict) or response.get("code") != 0: + raise UpgradeError("V1 固件信息查询失败或响应格式异常") + if not response.get("sn") or not response.get("dev_type"): + raise UpgradeError("V1 固件信息缺少 SN 或 dev_type,拒绝升级") + logger.emit("device_info", protocol="V1", response=response) + return response + + def response_data(response: Dict[str, Any]) -> Dict[str, Any]: """取得响应 data 对象,格式错误时拒绝继续。""" @@ -379,9 +405,11 @@ def build_parser() -> argparse.ArgumentParser: """创建命令行参数解析器。""" parser = argparse.ArgumentParser( - description="V2 设备 30101 身份预检与 EPFP/4001 ROM 升级;默认仅预检。" + description="设备身份预检与 EPFP/4001 ROM 升级;默认仅预检。" ) parser.add_argument("--host", required=True, help="目标设备 IPv4/主机名") + parser.add_argument("--protocol", choices=("v1", "v2"), default="v2", + help="身份查询协议;固件传输均使用 EPFP/4001") parser.add_argument("--expected-sn", required=True, help="必须精确匹配的完整设备 SN") parser.add_argument("--expected-model", help="可选,必须出现在设备型号字段中的型号") parser.add_argument("--rom", required=True, type=Path, help="ROM 文件绝对或相对路径") @@ -422,10 +450,23 @@ def main(argv: Optional[List[str]] = None) -> int: logger.emit("mode", execute=bool(args.execute), log_file=str(logger.path)) rom = validate_rom(args.rom) logger.emit("rom", **rom) - response = query_device_info(args.host, args.connect_timeout, logger) - data, models = validate_identity(response, args.expected_sn, args.expected_model) + if args.protocol == "v1": + response = query_v1_device_info(args.host, args.connect_timeout, logger) + if response.get("sn") != args.expected_sn: + raise UpgradeError("设备 SN 不匹配:期望 %s,实际 %r" % (args.expected_sn, response.get("sn"))) + if args.expected_model and args.expected_model not in str(response.get("dev_type", "")): + raise UpgradeError("设备型号不匹配:期望 %s,实际 %r" % (args.expected_model, response.get("dev_type"))) + data = {"sn": response.get("sn"), "inner": {"type": response.get("dev_type"), + "version": response.get("firmware")}, "outer": {"version": response.get("dev_ver")}, + "hardware": {"version": response.get("hardware")}} + models = [str(response["dev_type"])] + else: + response = query_device_info(args.host, args.connect_timeout, logger) + data, models = validate_identity(response, args.expected_sn, args.expected_model) summary = { "host": args.host, + "identity_protocol": args.protocol, + "upgrade_protocol_compatibility": "V1 身份查询 + EPFP/4001;仍须依据目标固件资料确认兼容性", "sn": data.get("sn"), "mac": data.get("mac"), "models": models, diff --git a/scripts/v1_command.py b/scripts/v1_command.py new file mode 100644 index 0000000..73ef0a6 --- /dev/null +++ b/scripts/v1_command.py @@ -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()) diff --git a/scripts/v1_discover.py b/scripts/v1_discover.py new file mode 100644 index 0000000..431fedf --- /dev/null +++ b/scripts/v1_discover.py @@ -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())