"""JSONL-only bridge for the Windows EchoEar flashing app. ``ports`` and ``preview`` never open a serial port. ``flash`` accepts only a short-lived, one-use preview session and an explicit execute=true acknowledgement. No credentials, setup passwords or raw subprocess output are sent to the GUI. """ from __future__ import annotations import argparse import contextlib from datetime import datetime, timezone import hashlib import hmac import io import json import os from pathlib import Path import re import secrets import subprocess import sys import time import device_flash import device_prepare PROJECT = Path(__file__).resolve().parents[1] DEFAULT_OUTPUT = PROJECT / "backups" / "gui" SESSION_TTL = 30 * 60 SESSION_FIELDS = {"schema_version", "created_at", "created_epoch", "expires_epoch", "credential_path", "build_dir", "mode", "expected_mac", "port", "factory_dir", "backup_dir", "input_hashes", "plan_hash", "signature"} PREVIEW_FIELDS = {"credential_path", "build_dir", "mode", "expected_mac", "port", "output_root"} FLASH_FIELDS = {"session_file", "execute", "port", "expected_mac"} MESSAGES = { "invalid_request": "输入无效。请检查烧录信息 JSON、固件目录、COM 端口和设备 MAC。", "invalid_credential": "烧录信息 JSON 无效,请从线上设备管理后台重新下载。", "invalid_build": "固件文件校验失败。请选择完整的 EchoEar ESP32-S3 16MB 构建目录。", "preparation_failed": "初始化信息生成失败。请检查 ESP-IDF Python 环境和输出目录权限,再重新预览。", "stale_preview": "预览已过期、已使用或输入文件发生变化。请重新预览后再烧录。", "confirmation_required": "尚未确认执行。请在界面中确认清除本地 Wi-Fi、语音、会话和 OTA 状态后再开始。", "port_mismatch": "设备端口或 MAC 与预览不一致。请重新预览。", "ports_failed": "串口列表读取失败。请检查 Python 环境是否安装 pyserial。", "flash_failed": "烧录未完成。请检查 USB 连接、设备 MAC 和串口占用;已生成的备份及诊断记录已保留。重新尝试前请重新预览。", "internal_error": "操作失败,未向界面输出敏感诊断内容。请检查运行环境和文件权限。", } STAGES = { "version": ("checking", "检查 esptool 版本"), "read_mac": ("checking", "连接设备并核对芯片与 MAC"), "flash_id": ("checking", "核对 16MB Flash 容量"), "get_security_info": ("checking", "核对设备安全配置"), "read_flash": ("backup", "正在完整备份 16MB Flash,请保持 USB 连接"), "write_flash": ("writing", "备份完成,正在写入,请保持 USB 连接"), "verify_flash": ("verifying", "正在校验设备中写入的数据"), } class BridgeError(ValueError): def __init__(self, code): self.code = code super().__init__(MESSAGES[code]) def require(condition, code="invalid_request"): if not condition: raise BridgeError(code) def canonical(value): return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") def digest(value): return hashlib.sha256(canonical(value)).hexdigest() def safe_path(value, code="invalid_request"): require(isinstance(value, str) and value and len(value) < 4096 and "\0" not in value, code) return Path(value).expanduser().resolve() def read_object(path, code="invalid_request"): try: require(path.is_file() and path.stat().st_size <= 65536, code) value = json.loads(path.read_text(encoding="utf-8-sig")) require(isinstance(value, dict), code) return value except (OSError, ValueError, UnicodeError): raise BridgeError(code) from None def normalize_port(value): require(isinstance(value, str) and re.fullmatch(r"COM[1-9][0-9]{0,4}", value, re.I)) return value.upper() def hidden_run(args, **kwargs): if os.name == "nt": kwargs["creationflags"] = kwargs.get("creationflags", 0) | subprocess.CREATE_NO_WINDOW return subprocess.run(args, **kwargs) class _HiddenSubprocess: """Keep existing preparation/ACL helpers quiet without changing their source.""" def run(self, args, **kwargs): return hidden_run(args, **kwargs) def __getattr__(self, name): return getattr(subprocess, name) @contextlib.contextmanager def hidden_preparation(): original = device_prepare.subprocess device_prepare.subprocess = _HiddenSubprocess() try: yield finally: device_prepare.subprocess = original def input_snapshot(credential, build, factory=None): """Include every reference build image even when retaining existing firmware.""" manifest = device_flash.load_json(build / "flasher_args.json") files = [credential, build / "flasher_args.json"] for name in device_flash.REGIONS: entry = device_flash.object_field(manifest, name) files.append(device_flash.contained_file(build, entry.get("file"))) if factory is not None: files.extend(factory / name for name in ("manifest.json", "ble_factory.bin", "factory.csv", "setup-label.json")) return {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(set(files))} def phase(emit, stage, message): emit({"event": "phase", "stage": stage, "message": message}) def enumerate_ports(request, emit): require(not request) try: from serial.tools import list_ports ports = [] for item in list_ports.comports(): if not re.fullmatch(r"COM[1-9][0-9]{0,4}", item.device, re.I): continue ports.append({"port": item.device.upper(), **{ name: getattr(item, name, None) for name in ("description", "hwid", "serial_number", "manufacturer", "product", "vid", "pid")}}) ports.sort(key=lambda item: int(item["port"][3:])) except Exception: raise BridgeError("ports_failed") from None emit({"event": "ports", "ports": ports}) def preview(request, emit): require(set(request) <= PREVIEW_FIELDS) phase(emit, "validating", "离线检查初始化信息和固件文件,不连接设备") credential = safe_path(request.get("credential_path")) build = safe_path(request.get("build_dir", str(PROJECT / "build"))) mode = request.get("mode", "identity") require(mode in ("identity", "full")) port = normalize_port(request.get("port")) try: mac = device_flash.normalize_mac(request.get("expected_mac")) except ValueError: raise BridgeError("invalid_request") from None output = safe_path(request.get("output_root", str(DEFAULT_OUTPUT))) try: require(credential.is_file() and credential.stat().st_size <= 16384, "invalid_credential") credential_hash = hashlib.sha256(credential.read_bytes()).hexdigest() info = device_prepare.read_credential(credential) except (ValueError, OSError): raise BridgeError("invalid_credential") from None try: original_inputs = input_snapshot(credential, build) require(original_inputs[str(credential)] == credential_hash, "stale_preview") device_flash.build_plan(build, reset_runtime=True) except BridgeError: raise except (ValueError, OSError): raise BridgeError("invalid_build") from None phase(emit, "preparing", "生成本机私有初始化文件和烧录预览") now = time.time() created_at = datetime.fromtimestamp(now, timezone.utc).isoformat() directory = output / (datetime.fromtimestamp(now, timezone.utc).strftime("%Y%m%dT%H%M%SZ-") + secrets.token_hex(6)) factory, backup = directory / "factory", directory / "backup" try: with hidden_preparation(): device_prepare.private_directory(directory) device_prepare.prepare(credential, factory, mac) plan = device_flash.build_plan(build, factory, mode == "identity", True, mac) require(original_inputs == input_snapshot(credential, build), "stale_preview") frozen_inputs = input_snapshot(credential, build, factory) session = {"schema_version": 1, "created_at": created_at, "created_epoch": now, "expires_epoch": now + SESSION_TTL, "credential_path": str(credential), "build_dir": str(build), "mode": mode, "expected_mac": mac, "port": port, "factory_dir": str(factory), "backup_dir": str(backup), "input_hashes": frozen_inputs, "plan_hash": digest(plan.summary())} integrity_key = secrets.token_bytes(32) key_path = directory / "integrity.key" with key_path.open("xb") as stream: stream.write(integrity_key) os.chmod(key_path, 0o600) session["signature"] = hmac.new(integrity_key, canonical(session), hashlib.sha256).hexdigest() session_file = directory / "session.json" device_prepare.private_json(session_file, session) except BridgeError: raise except Exception: raise BridgeError("preparation_failed") from None summary = {"mode": mode, "port": port, "expected_mac": mac, "device_id": info["device_id"], "model": info["model"], "origin": info["origin"], "key_version": info["key_version"], "firmware_version": None if mode == "identity" else plan.application["firmware_version"], "reference_firmware_version": plan.application["firmware_version"], "reset_runtime": True, "runtime_effect": "清除本地 Wi-Fi、语音、会话和 OTA 状态;完成后须重新蓝牙配网。", "regions": [{key: value for key, value in image.summary().items() if key != "source"} for image in plan.images], "factory_dir": str(factory), "backup_dir": str(backup), "created_at": created_at, "expires_in_seconds": SESSION_TTL} phase(emit, "ready", "预览完成。确认清除配网信息后方可开始烧录") emit({"event": "preview", "session_file": str(session_file), "summary": summary}) def load_session(path): require(path.name == "session.json", "stale_preview") session = read_object(path, "stale_preview") require(set(session) == SESSION_FIELDS and session.get("schema_version") == 1, "stale_preview") signature = session.pop("signature") try: key = (path.parent / "integrity.key").read_bytes() require(len(key) == 32 and isinstance(signature, str), "stale_preview") require(hmac.compare_digest(signature, hmac.new(key, canonical(session), hashlib.sha256).hexdigest()), "stale_preview") require(type(session["created_epoch"]) in (int, float) and type(session["expires_epoch"]) in (int, float), "stale_preview") now = time.time() require(session["created_epoch"] - 60 <= now < session["expires_epoch"], "stale_preview") require(session["expires_epoch"] - session["created_epoch"] == SESSION_TTL, "stale_preview") require(session["mode"] in ("identity", "full"), "stale_preview") require(normalize_port(session["port"]) == session["port"], "stale_preview") require(device_flash.normalize_mac(session["expected_mac"]) == session["expected_mac"], "stale_preview") require(safe_path(session["factory_dir"]) == path.parent / "factory", "stale_preview") require(safe_path(session["backup_dir"]) == path.parent / "backup", "stale_preview") require(not (path.parent / "execute.json").exists(), "stale_preview") except (OSError, ValueError, TypeError, KeyError): raise BridgeError("stale_preview") from None return session def flash(request, emit): require(set(request) <= FLASH_FIELDS) require(request.get("execute") is True, "confirmation_required") path = safe_path(request.get("session_file"), "stale_preview") session = load_session(path) if "port" in request: require(normalize_port(request["port"]) == session["port"], "port_mismatch") if "expected_mac" in request: try: require(device_flash.normalize_mac(request["expected_mac"]) == session["expected_mac"], "port_mismatch") except device_flash.FlashError: raise BridgeError("port_mismatch") from None phase(emit, "validating", "重新核对已确认的文件、端口、MAC 和烧录范围") credential = safe_path(session["credential_path"], "stale_preview") build = safe_path(session["build_dir"], "stale_preview") factory = safe_path(session["factory_dir"], "stale_preview") try: require(input_snapshot(credential, build, factory) == session["input_hashes"], "stale_preview") device_prepare.read_credential(credential) plan = device_flash.build_plan(build, factory, session["mode"] == "identity", True, session["expected_mac"]) require(digest(plan.summary()) == session["plan_hash"], "stale_preview") require(input_snapshot(credential, build, factory) == session["input_hashes"], "stale_preview") # Exclusive creation consumes the session before even the first probe. device_prepare.private_json(path.parent / "execute.json", {"started_at": datetime.now(timezone.utc).isoformat()}) except (ValueError, OSError): raise BridgeError("stale_preview") from None def runner(args, **kwargs): for argument in args: stage = STAGES.get(argument.replace("-", "_")) if stage: phase(emit, *stage) break return hidden_run(args, **kwargs) try: # Existing CLI progress and raw tool output are intentionally not GUI logs. with hidden_preparation(), contextlib.redirect_stdout(io.StringIO()): result = device_flash.execute_plan(plan, python=sys.executable, port=session["port"], expect_mac=session["expected_mac"], backup_dir=Path(session["backup_dir"]), runner=runner) except Exception: raise BridgeError("flash_failed") from None result["next_step"] = "烧录和校验完成。请手动重启设备,然后在家庭端重新蓝牙配网和绑定。" emit({"event": "complete", "result": result}) def main(argv=None, output=None): output = output or sys.stdout def emit(value): output.write(json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n") output.flush() parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("command", choices=("ports", "preview", "flash")) parser.add_argument("--request", required=True, type=Path) try: args = parser.parse_args(argv) request = read_object(args.request) {"ports": enumerate_ports, "preview": preview, "flash": flash}[args.command](request, emit) return 0 except BridgeError as exc: emit({"event": "error", "code": exc.code, "message": MESSAGES[exc.code]}) return 2 except Exception: emit({"event": "error", "code": "internal_error", "message": MESSAGES["internal_error"]}) return 2 if __name__ == "__main__": raise SystemExit(main())