Add V1 protocol discovery and upgrade support

This commit is contained in:
zoukai committed 2026-10-21 10:16:52 +08:00
1 parent 63a32aaf02
commit d50be29246
5 files changed
+240 -6

No files matched your search

+45 -4
View File
@@ -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,
+70
View File
@@ -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
View File
@@ -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())