#!/usr/bin/env python3 """V1/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 V1_INFO_PORT = 4941 V1_INFO_COMMAND = 0x5A0E2104 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 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 对象,格式错误时拒绝继续。""" 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="设备身份预检与 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 文件绝对或相对路径") 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) 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, "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())