diff --git a/.gitignore b/.gitignore index 74109bd..24c1001 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,19 @@ __pycache__/ managed_components JX_2R_01测试程序_STM32F103RF + +# esptool-factory local/runtime artifacts +tools/esptool-factory/.venv/ +tools/esptool-factory/.pyinstaller/ +tools/esptool-factory/build/ +tools/esptool-factory/dist/ +tools/esptool-factory/bin/ +tools/esptool-factory/*.spec +tools/esptool-factory/**/__pycache__/ +tools/esptool-factory/**/*.pyc +tools/esptool-factory/.DS_Store + +# esptool-factory local secrets and keys +tools/esptool-factory/conf/factory_private.json +tools/esptool-factory/keys/**/* +!tools/esptool-factory/keys/README.md diff --git a/partitions.csv b/partitions.csv index c721d7e..1d57b80 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,4 +1,8 @@ -# Name, Type, SubType, Offset, Size, Flags -nvs, data, nvs, , 0x6000, -phy_init, data, phy, , 0x1000, -factory, app, factory, , 4M, +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x11000, 0x10000, +otadata, data, ota, 0x21000, 0x2000, +phy_init, data, phy, 0x23000, 0x1000, +ota_0, app, ota_0, 0x30000, 0x780000, +ota_1, app, ota_1, 0x7B0000, 0x780000, +nvs_key, data, nvs_keys,0xF30000, 0x1000, encrypted, \ No newline at end of file diff --git a/sdkconfig.defaults b/sdkconfig.defaults index c724b5c..0979546 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -24,6 +24,7 @@ CONFIG_TQ_Z_IMAGE_HTTP_READ_CHUNK_BYTES=16384 # CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_OFFSET=0x10000 CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y CONFIG_ESPTOOLPY_FLASHSIZE="16MB" @@ -100,3 +101,17 @@ CONFIG_TQ_SCREEN_SWAP_XY=y CONFIG_TQ_SCREEN_X_GAP=0 CONFIG_TQ_SCREEN_Y_GAP=0 CONFIG_TQ_SCREEN_RESET_PIN=-1 + +# Security features (dev mode: Secure Boot v2 + keep UART ROM download enabled) +CONFIG_SECURE_BOOT=y +CONFIG_SECURE_BOOT_V2_ENABLED=y +CONFIG_SECURE_SIGNED_APPS=y +CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME=y +CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES=y +CONFIG_SECURE_BOOT_SIGNING_KEY="tools/esptool-factory/keys/secure_boot/secure_boot_signing_key_dev.pem" +CONFIG_SECURE_BOOT_FLASH_BOOTLOADER_DEFAULT=y +CONFIG_SECURE_FLASH_ENC_ENABLED=y +CONFIG_SECURE_FLASH_ENCRYPTION_MODE_DEVELOPMENT=y +CONFIG_SECURE_INSECURE_ALLOW_DL_MODE=y +# CONFIG_SECURE_DISABLE_ROM_DL_MODE is not set +# CONFIG_SECURE_ENABLE_SECURE_ROM_DL_MODE is not set diff --git a/tools/esptool-factory/README.md b/tools/esptool-factory/README.md new file mode 100644 index 0000000..f43b7dc --- /dev/null +++ b/tools/esptool-factory/README.md @@ -0,0 +1,79 @@ +# Factory Flashing Toolkit (Local-Only) + +This toolkit now runs in local-only mode. + +- Network workflows are not used. +- `flash.bin_encryption` config item is no longer supported. +- Firmware package format is **always** `.bin.enc` + `.sig`. + +## Quick Start + +### 1) Build firmware + +From project root: + +```bash +idf.py build +``` + +The packaging script auto-syncs required plaintext build outputs from `build/flasher_args.json` +into `tools/esptool-factory/bin/`, then encrypts them into `.bin.enc` for distribution. + +### 2) Prepare private config + +Use plaintext config: + +- `tools/esptool-factory/conf/factory_private.json` + +Requirements: + +- Do not set `flash.bin_encryption`. +- Keep `flash.layout` / `app_bin_name` aligned with current build outputs. +- `flash.app_bin_name` must exist in `flash.layout`, and `flash.app_partition_offset` + must match that layout entry offset. +- `flash.flash_args` (`flash_mode`/`flash_freq`/`flash_size`/`before`/`after`) + must use supported enum values. +- `flash.layout` uses original build artifact names (for example `bootloader.bin`); + the packager converts them to `bootloader.bin.enc` automatically. + +### 3) Package + +From project root: + +```bash +tools/esptool-factory/.venv/bin/python3 tools/esptool-factory/package_factory_tools.py +``` + +The packager always uses: + +- `tools/esptool-factory/keys/firmware/fw_key.bin` + +and copies it into the release package under `keys/firmware/fw_key.bin`. + +Default output: + +- `tools/esptool-factory/dist/factory/` + +You can change output folder name: + +```bash +tools/esptool-factory/.venv/bin/python3 tools/esptool-factory/package_factory_tools.py \ + --package-name line-a +``` + +### 4) Factory use + +1. Run packaged GUI. +2. Connect device and click start. +3. Tool executes: device check -> flash -> post-flash check. + - If secure boot is already enabled but flash `0x0` header is blank/invalid, + GUI will switch to initial full-flash provisioning (with force semantics where needed) + instead of app-only. + - `force` is gated to `bootloader@0x0` only. Before writing, GUI re-checks + bootloader image chip/revision compatibility and blocks plaintext force if + flash encryption appears enabled. + +No environment variable is required for `.enc` decrypt key. +The GUI reads `keys/firmware/fw_key.bin` from the package directory. + +No network endpoint is required. diff --git a/tools/esptool-factory/factory_common/__init__.py b/tools/esptool-factory/factory_common/__init__.py new file mode 100644 index 0000000..72de442 --- /dev/null +++ b/tools/esptool-factory/factory_common/__init__.py @@ -0,0 +1,21 @@ +"""Common helpers for factory tooling.""" + +from .firmware_envelope import ( + FirmwareEnvelopeError, + decrypt_firmware_blob, + encrypt_firmware_blob, + load_firmware_decrypt_key, + load_firmware_encrypt_key, +) +from .signing import SignatureError, sign_file, verify_file_signature + +__all__ = [ + "FirmwareEnvelopeError", + "SignatureError", + "decrypt_firmware_blob", + "encrypt_firmware_blob", + "load_firmware_decrypt_key", + "load_firmware_encrypt_key", + "sign_file", + "verify_file_signature", +] diff --git a/tools/esptool-factory/factory_common/firmware_envelope.py b/tools/esptool-factory/factory_common/firmware_envelope.py new file mode 100644 index 0000000..5579196 --- /dev/null +++ b/tools/esptool-factory/factory_common/firmware_envelope.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import base64 +import hashlib +import os +import sys +from pathlib import Path +from typing import Iterable + +try: + from cryptography.exceptions import InvalidTag + from cryptography.hazmat.primitives.ciphers.aead import AESGCM +except Exception as exc: # noqa: BLE001 + AESGCM = None # type: ignore[assignment] + InvalidTag = Exception # type: ignore[assignment] + _CRYPTO_IMPORT_ERROR = exc +else: + _CRYPTO_IMPORT_ERROR = None + + +MAGIC = b"TQFWENC1" +NONCE_SIZE = 12 +SHA256_SIZE = 32 +MIN_PAYLOAD_SIZE = len(MAGIC) + NONCE_SIZE + SHA256_SIZE + 16 +DEFAULT_FIRMWARE_KEY_RELATIVE_PATH = Path("keys") / "firmware" / "fw_key.bin" + + +class FirmwareEnvelopeError(Exception): + pass + + +def _require_crypto() -> None: + if _CRYPTO_IMPORT_ERROR is not None or AESGCM is None: + raise FirmwareEnvelopeError("cryptography 不可用,无法处理 .enc 固件。") + + +def _parse_key_material(raw: bytes, source: str) -> bytes: + if len(raw) == 32: + return raw + + try: + text = raw.decode("utf-8").strip() + except UnicodeDecodeError as exc: + raise FirmwareEnvelopeError(f"固件密钥格式无效({source}):既不是 32 字节原始密钥,也不是文本编码。") from exc + + if not text: + raise FirmwareEnvelopeError(f"固件密钥为空({source})。") + + lowered = text.lower() + if lowered.startswith("hex:"): + text = text[4:].strip() + if len(text) == 64: + try: + key = bytes.fromhex(text) + except ValueError as exc: + raise FirmwareEnvelopeError(f"固件密钥 HEX 无效({source})。") from exc + if len(key) == 32: + return key + + if lowered.startswith("base64:"): + text = text[7:].strip() + try: + key = base64.b64decode(text.encode("utf-8"), validate=True) + except Exception: # noqa: BLE001 + key = b"" + if len(key) == 32: + return key + + raise FirmwareEnvelopeError( + f"固件密钥长度无效({source}):需要 32 字节,或 64 位 HEX,或 32 字节 Base64。" + ) + + +def _iter_runtime_roots() -> Iterable[Path]: + exe_path = Path(sys.executable).resolve() + yield exe_path.parent + for parent in exe_path.parents: + if parent.suffix.lower() == ".app": + yield parent.parent + break + + +def _candidate_key_paths() -> Iterable[Path]: + if getattr(sys, "frozen", False): + for root in _iter_runtime_roots(): + yield root / DEFAULT_FIRMWARE_KEY_RELATIVE_PATH + + for parent in Path(__file__).resolve().parents: + yield parent / DEFAULT_FIRMWARE_KEY_RELATIVE_PATH + + +def _load_key_from_fixed_path(purpose: str) -> bytes: + for path in _candidate_key_paths(): + if not path.exists(): + continue + try: + raw = path.read_bytes() + except OSError as exc: + raise FirmwareEnvelopeError(f"{purpose}密钥文件读取失败:{path}") from exc + return _parse_key_material(raw, str(path)) + + raise FirmwareEnvelopeError( + f"缺少{purpose}密钥文件。请在运行目录提供 {DEFAULT_FIRMWARE_KEY_RELATIVE_PATH}。" + ) + + +def load_firmware_encrypt_key() -> bytes: + return _load_key_from_fixed_path(purpose="固件加密") + + +def load_firmware_decrypt_key() -> bytes: + return _load_key_from_fixed_path(purpose="固件解密") + + +def encrypt_firmware_blob(plain: bytes, key: bytes) -> bytes: + _require_crypto() + if len(key) != 32: + raise FirmwareEnvelopeError("固件加密密钥长度无效,必须为 32 字节。") + nonce = os.urandom(NONCE_SIZE) + digest = hashlib.sha256(plain).digest() + aes = AESGCM(key) + ciphertext = aes.encrypt(nonce, plain, MAGIC) + return MAGIC + nonce + digest + ciphertext + + +def decrypt_firmware_blob(payload: bytes, key: bytes) -> bytes: + _require_crypto() + if len(key) != 32: + raise FirmwareEnvelopeError("固件解密密钥长度无效,必须为 32 字节。") + if len(payload) < MIN_PAYLOAD_SIZE: + raise FirmwareEnvelopeError("固件 .enc 文件格式无效:长度不足。") + if payload[: len(MAGIC)] != MAGIC: + raise FirmwareEnvelopeError("固件 .enc 文件格式无效:magic 不匹配。") + + cursor = len(MAGIC) + nonce = payload[cursor : cursor + NONCE_SIZE] + cursor += NONCE_SIZE + expected_digest = payload[cursor : cursor + SHA256_SIZE] + cursor += SHA256_SIZE + ciphertext = payload[cursor:] + + aes = AESGCM(key) + try: + plain = aes.decrypt(nonce, ciphertext, MAGIC) + except InvalidTag as exc: + raise FirmwareEnvelopeError("固件 .enc 解密失败:密钥错误或密文损坏。") from exc + except Exception as exc: # noqa: BLE001 + raise FirmwareEnvelopeError("固件 .enc 解密失败。") from exc + + digest = hashlib.sha256(plain).digest() + if digest != expected_digest: + raise FirmwareEnvelopeError("固件 .enc 解密后哈希不一致。") + return plain diff --git a/tools/esptool-factory/factory_common/idf_artifacts.py b/tools/esptool-factory/factory_common/idf_artifacts.py new file mode 100644 index 0000000..8113345 --- /dev/null +++ b/tools/esptool-factory/factory_common/idf_artifacts.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Iterable + + +def _load_json(path: Path) -> dict: + try: + raw = path.read_text(encoding="utf-8") + except OSError: + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def _from_build(build_dir: Path, relative_path: object) -> Path | None: + if not isinstance(relative_path, str) or not relative_path.strip(): + return None + candidate = build_dir / relative_path + if candidate.exists() and candidate.is_file(): + return candidate + return None + + +def _collect_from_flasher_args(build_dir: Path) -> dict[str, Path]: + result: dict[str, Path] = {} + data = _load_json(build_dir / "flasher_args.json") + + flash_files = data.get("flash_files") + if isinstance(flash_files, dict): + for rel in flash_files.values(): + path = _from_build(build_dir, rel) + if path is not None: + result[path.name] = path + + for key in ("bootloader", "app", "partition-table"): + section = data.get(key) + if not isinstance(section, dict): + continue + path = _from_build(build_dir, section.get("file")) + if path is not None: + result[path.name] = path + + return result + + +def _find_in_build_tree(build_dir: Path, name: str) -> Path | None: + direct = build_dir / name + if direct.exists() and direct.is_file(): + return direct + for path in build_dir.glob(f"**/{name}"): + if path.is_file(): + return path + return None + + +def sync_bins_from_idf_build( + bin_dir: Path, + required_bins: Iterable[str], + build_dir: Path, +) -> list[tuple[Path, Path]]: + if not build_dir.exists() or not build_dir.is_dir(): + return [] + + build_map = _collect_from_flasher_args(build_dir) + copied: list[tuple[Path, Path]] = [] + seen: set[str] = set() + for raw_name in required_bins: + name = str(raw_name).strip() + if not name or name in seen: + continue + seen.add(name) + + target = bin_dir / name + if target.exists(): + continue + + source = build_map.get(name) + if source is None: + source = _find_in_build_tree(build_dir, name) + if source is None: + continue + + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + copied.append((source, target)) + return copied diff --git a/tools/esptool-factory/factory_common/signing.py b/tools/esptool-factory/factory_common/signing.py new file mode 100644 index 0000000..5a70a20 --- /dev/null +++ b/tools/esptool-factory/factory_common/signing.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +from factory_common.signing_pubkey import PUBLIC_KEY_PEM + +try: + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, ed448, padding, rsa +except Exception as exc: # noqa: BLE001 + InvalidSignature = None # type: ignore[assignment] + hashes = None # type: ignore[assignment] + serialization = None # type: ignore[assignment] + dsa = ec = ed25519 = ed448 = padding = rsa = None # type: ignore[assignment] + _CRYPTO_IMPORT_ERROR = exc +else: + _CRYPTO_IMPORT_ERROR = None + + +class SignatureError(Exception): + pass + + +def _require_crypto() -> None: + if _CRYPTO_IMPORT_ERROR is not None or serialization is None: + raise SignatureError("cryptography is required for signature operations") + + +def sig_path_for(path: Path) -> Path: + return Path(str(path) + ".sig") + + +def _load_public_key(pem: bytes) -> object: + _require_crypto() + if not pem: + raise SignatureError("public key is empty") + try: + return serialization.load_pem_public_key(pem) + except Exception as exc: # noqa: BLE001 + raise SignatureError("invalid public key format") from exc + + +def _load_private_key(path: Path, password: Optional[str]) -> object: + _require_crypto() + try: + data = path.read_bytes() + except OSError as exc: + raise SignatureError(f"unable to read private key: {path}") from exc + password_bytes = password.encode("utf-8") if password else None + try: + return serialization.load_pem_private_key(data, password=password_bytes) + except (TypeError, ValueError) as exc: + raise SignatureError("invalid private key or password") from exc + + +def _sha256_b64(data: bytes) -> str: + digest = hashlib.sha256(data).digest() + return base64.b64encode(digest).decode("ascii") + + +def _sign_bytes(data: bytes, private_key: object) -> Tuple[bytes, str]: + _require_crypto() + if ed25519 and isinstance(private_key, ed25519.Ed25519PrivateKey): + return private_key.sign(data), "ed25519" + if ed448 and isinstance(private_key, ed448.Ed448PrivateKey): + return private_key.sign(data), "ed448" + if ec and isinstance(private_key, ec.EllipticCurvePrivateKey): + return private_key.sign(data, ec.ECDSA(hashes.SHA256())), "ecdsa-sha256" + if rsa and isinstance(private_key, rsa.RSAPrivateKey): + signature = private_key.sign( + data, + padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH), + hashes.SHA256(), + ) + return signature, "rsa-pss-sha256" + if dsa and isinstance(private_key, dsa.DSAPrivateKey): + return private_key.sign(data, hashes.SHA256()), "dsa-sha256" + raise SignatureError("unsupported private key type") + + +def _verify_bytes(data: bytes, signature: bytes, public_key: object, alg_hint: Optional[str]) -> None: + _require_crypto() + try: + if ed25519 and isinstance(public_key, ed25519.Ed25519PublicKey): + public_key.verify(signature, data) + return + if ed448 and isinstance(public_key, ed448.Ed448PublicKey): + public_key.verify(signature, data) + return + if ec and isinstance(public_key, ec.EllipticCurvePublicKey): + public_key.verify(signature, data, ec.ECDSA(hashes.SHA256())) + return + if rsa and isinstance(public_key, rsa.RSAPublicKey): + public_key.verify( + signature, + data, + padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH), + hashes.SHA256(), + ) + return + if dsa and isinstance(public_key, dsa.DSAPublicKey): + public_key.verify(signature, data, hashes.SHA256()) + return + except InvalidSignature as exc: + raise SignatureError("signature verification failed") from exc + except Exception as exc: # noqa: BLE001 + raise SignatureError("signature verification failed") from exc + raise SignatureError("unsupported public key type") + + +def _parse_signature(sig_path: Path) -> Tuple[bytes, Dict[str, Any]]: + try: + raw = sig_path.read_bytes() + except OSError as exc: + raise SignatureError(f"unable to read signature file: {sig_path}") from exc + text = raw.decode("utf-8", errors="strict").strip() + try: + payload = json.loads(text) + except json.JSONDecodeError: + payload = None + if isinstance(payload, dict): + sig_b64 = payload.get("sig_b64") + if not isinstance(sig_b64, str): + raise SignatureError("signature file missing sig_b64") + try: + signature = base64.b64decode(sig_b64.encode("ascii")) + except Exception as exc: # noqa: BLE001 + raise SignatureError("invalid base64 signature") from exc + return signature, payload + try: + signature = base64.b64decode(text.encode("ascii")) + except Exception as exc: # noqa: BLE001 + raise SignatureError("invalid signature format") from exc + return signature, {} + + +def sign_file(path: Path, private_key_path: Path, password: Optional[str] = None) -> Path: + data = path.read_bytes() + private_key = _load_private_key(private_key_path, password) + signature, alg = _sign_bytes(data, private_key) + payload = { + "alg": alg, + "hash": "sha256", + "hash_b64": _sha256_b64(data), + "sig_b64": base64.b64encode(signature).decode("ascii"), + } + sig_path = sig_path_for(path) + sig_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return sig_path + + +def verify_file_signature(path: Path) -> None: + sig_path = sig_path_for(path) + if not sig_path.exists(): + raise SignatureError(f"missing signature file: {sig_path.name}") + signature, payload = _parse_signature(sig_path) + data = path.read_bytes() + hash_b64 = payload.get("hash_b64") + if isinstance(hash_b64, str): + current = _sha256_b64(data) + if current != hash_b64: + raise SignatureError("content hash mismatch") + public_key = _load_public_key(PUBLIC_KEY_PEM) + alg_hint = payload.get("alg") if isinstance(payload, dict) else None + _verify_bytes(data, signature, public_key, alg_hint) + + +def verify_payload_signature(payload: bytes, sig_b64: str, alg_hint: Optional[str] = None) -> None: + _require_crypto() + if not isinstance(sig_b64, str) or not sig_b64.strip(): + raise SignatureError("missing signature") + try: + signature = base64.b64decode(sig_b64.encode("ascii")) + except Exception as exc: # noqa: BLE001 + raise SignatureError("invalid base64 signature") from exc + public_key = _load_public_key(PUBLIC_KEY_PEM) + _verify_bytes(payload, signature, public_key, alg_hint) diff --git a/tools/esptool-factory/factory_common/signing_pubkey.py b/tools/esptool-factory/factory_common/signing_pubkey.py new file mode 100644 index 0000000..0c4ee34 --- /dev/null +++ b/tools/esptool-factory/factory_common/signing_pubkey.py @@ -0,0 +1,51 @@ +"""Factory signing public key loader (single source of truth).""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Iterable + + +_DEFAULT_RELATIVE_PATH = Path("keys") / "factory_signing" / "factory_signing_pubkey.pem" + +def _iter_runtime_roots() -> Iterable[Path]: + exe_path = Path(sys.executable).resolve() + yield exe_path.parent + for parent in exe_path.parents: + if parent.suffix.lower() == ".app": + yield parent.parent + break + + +def _candidate_paths() -> Iterable[Path]: + env_path = os.getenv("FACTORY_SIGNING_PUBKEY_PATH") + if env_path: + yield Path(env_path).expanduser() + + if getattr(sys, "frozen", False): + for root in _iter_runtime_roots(): + yield root / _DEFAULT_RELATIVE_PATH + + for parent in Path(__file__).resolve().parents: + yield parent / _DEFAULT_RELATIVE_PATH + + +def _load_public_key_pem() -> bytes: + for path in _candidate_paths(): + if path.exists(): + data = path.read_bytes() + try: + data.decode("ascii") + except UnicodeDecodeError as exc: + raise RuntimeError(f"Signing public key must be ASCII PEM: {path}") from exc + return data + raise RuntimeError( + "Factory signing public key not found. " + "Set FACTORY_SIGNING_PUBKEY_PATH or place " + f"{_DEFAULT_RELATIVE_PATH} next to the executable." + ) + + +PUBLIC_KEY_PEM = _load_public_key_pem() diff --git a/tools/esptool-factory/factory_gui/__init__.py b/tools/esptool-factory/factory_gui/__init__.py new file mode 100644 index 0000000..0be18ea --- /dev/null +++ b/tools/esptool-factory/factory_gui/__init__.py @@ -0,0 +1 @@ +"""Factory flashing GUI modules.""" diff --git a/tools/esptool-factory/factory_gui/app.py b/tools/esptool-factory/factory_gui/app.py new file mode 100644 index 0000000..8b42ee4 --- /dev/null +++ b/tools/esptool-factory/factory_gui/app.py @@ -0,0 +1,2316 @@ +from __future__ import annotations + +import getpass +import hashlib +import json +import os +import platform +import re +import sys +import threading +import tempfile +import time +import tkinter as tk +import uuid +from datetime import datetime, timezone +from pathlib import Path +from tkinter import messagebox, scrolledtext, ttk +from typing import Any, Callable, Iterable, Optional, Tuple + +from factory_common.firmware_envelope import ( + FirmwareEnvelopeError, + decrypt_firmware_blob, + load_firmware_decrypt_key, +) +from factory_common.signing import SignatureError, verify_file_signature +from factory_private_config import ( + EncryptedConfigError, + load_private_config_from_path, + resolve_private_config_path, +) + +from .config import ( + _load_flash_private_config, + _ui_text, +) +from .constants import ( + DEFAULT_BAUD, + DEFAULT_FLASH_MAX_ATTEMPTS, + DEFAULT_FLASH_SECURE_MAX_ATTEMPTS, + DEFAULT_READY_TIMEOUT_S, +) +from .flash_utils import ( + _extract_json_blob, + _parse_offset, + _sha256_hex, + _value_to_int, + _wipe_bytearray, + build_flash_entries, + collect_error_hints, + detect_boot_chain_header_state, + detect_secure_boot_block, + extract_mac_from_output, + evaluate_post_flash_status, + get_efuse_flag, + recommend_mode_from_json, +) +from .models import ( + DeviceCheckConfig, + FlashEntryReport, + FlashPrivateConfig, + FlashReport, + FlashSource, +) +from .path_utils import _display_path, get_base_dir, is_frozen, resolve_python, select_bin_dir +from .redact import _redact_sensitive, _sanitize_filename +from .serial_utils import ensure_pyserial, list_serial_ports, pick_preferred_port +from .time_utils import _now_iso +from .tooling import run_command, run_esptool_api, run_tool_module + + +class FlashGUI: + def __init__(self) -> None: + self.base_dir = get_base_dir() + self.frozen = is_frozen() + self.python_cmd: Optional[Path] = None if self.frozen else resolve_python(self.base_dir) + self.run_record: Optional[dict] = None + self.current_device_id: Optional[str] = None + self.device_id_hint: Optional[str] = None + self._post_flash_reset_done = False + + self.ready_timeout_s = DEFAULT_READY_TIMEOUT_S + self.flash_max_attempts = DEFAULT_FLASH_MAX_ATTEMPTS + self.flash_secure_max_attempts = DEFAULT_FLASH_SECURE_MAX_ATTEMPTS + + self.private_cfg_path = resolve_private_config_path(self.base_dir) + if self.private_cfg_path is None or not self.private_cfg_path.exists(): + raise FileNotFoundError( + "缺少工厂私有配置文件(factory_private.json)。请设置环境变量 " + "FACTORY_PRIVATE_CONFIG/FACTORY_PRIVATE_CONFIG_PATH 指定配置路径。" + ) + self.private_cfg_loaded = False + + self.ui_title = "产线烧录工具" + self.ui_subtitle = "插入设备后点击开始,系统将自动完成流程" + self.label_port = "串口" + self.label_secure = "启动信息" + self.label_flash_enc = "写入信息" + self.label_key = "配置信息" + self.label_recommend = "下一步" + self.label_log = "日志" + self.plain_text = "模式 A" + self.app_text = "模式 C" + self.safe_log = True + + raw_cfg: Optional[dict] = None + try: + raw_cfg = load_private_config_from_path(self.private_cfg_path) + except EncryptedConfigError: + raw_cfg = None + if raw_cfg is not None: + self._apply_private_config(raw_cfg) + else: + self.private_cfg = self._build_placeholder_config() + + self.root = tk.Tk() + self._apply_window_icon() + ui_cfg = self.private_cfg.ui + self.ui_title = _ui_text(ui_cfg, "flash_title", "产线烧录工具") + self.ui_subtitle = _ui_text(ui_cfg, "flash_subtitle", "插入设备后点击开始,系统将自动完成流程") + self.label_port = _ui_text(ui_cfg, "label_port", "串口") + self.label_secure = _ui_text(ui_cfg, "label_secure", "启动信息") + self.label_flash_enc = _ui_text(ui_cfg, "label_flash_enc", "写入信息") + self.label_key = _ui_text(ui_cfg, "label_key", "配置信息") + self.label_recommend = _ui_text(ui_cfg, "label_recommend", "下一步") + self.label_log = _ui_text(ui_cfg, "label_log", "日志") + safe_log_value = ui_cfg.get("safe_log") + self.safe_log = bool(safe_log_value) if isinstance(safe_log_value, bool) else True + + self.root.title(self.ui_title) + self.root.geometry("820x620") + self.root.minsize(760, 560) + self.root.resizable(True, True) + + self.plain_text = _ui_text(ui_cfg, "flash_mode_plain", "模式 A") + self.app_text = _ui_text(ui_cfg, "flash_mode_app", "模式 C") + + self.port_var = tk.StringVar(value="COM3") + self.status_var = tk.StringVar(value="就绪。") + self.recommend_var = tk.StringVar(value="插入设备后点击开始。") + self.hint_var = tk.StringVar(value="") + self.mode_status_var = tk.StringVar(value="") + self.key_status_var = tk.StringVar(value="") + self.progress_value = tk.DoubleVar(value=0.0) + self.active_mode_label = "" + self.suppress_ready_status = False + self.checked_port: Optional[str] = None + self._last_port_value = "" + + self.worker: Optional[threading.Thread] = None + self.is_busy = False + self.summary_attempted = False + self.summary_ok = False + self.last_rec_mode: Optional[str] = None + self.last_flash_app_only: Optional[bool] = None + self.secure_boot_blocked = False + self.secure_boot_enabled: Optional[bool] = None + self.boot_chain_present: Optional[bool] = None + self.boot_chain_probe_reason: Optional[str] = None + self.download_mode_disabled: Optional[bool] = None + self.secure_download_enabled: Optional[bool] = None + self.manual_encrypt_disabled: Optional[bool] = None + self.last_flash_block_reason: Optional[str] = None + self.card_port_var = tk.StringVar(value="未选择") + self.card_secure_boot_var = tk.StringVar(value="未检测") + self.card_flash_enc_var = tk.StringVar(value="未检测") + self.card_recommend_var = tk.StringVar(value="待检测") + self._build_ui() + self.preflight_check() + self._auto_set_port(initial=True) + self._last_port_value = self._normalize_port(self.port_var.get()) + self.port_var.trace_add("write", self._on_port_change) + + def _apply_window_icon(self) -> None: + icon_path = self._resolve_icon_path() + if not icon_path.exists(): + return + try: + self.root.iconbitmap(default=str(icon_path)) + except Exception: # noqa: BLE001 + pass + + def _resolve_icon_path(self) -> Path: + if self.frozen and hasattr(sys, "_MEIPASS"): + return Path(getattr(sys, "_MEIPASS")) / "talkingq_logo_256x256.ico" + return self.base_dir / "talkingq_logo_256x256.ico" + + def _build_placeholder_config(self) -> FlashPrivateConfig: + device_check = DeviceCheckConfig( + crypt_cnt_fields=("SPI_BOOT_CRYPT_CNT",), + secure_boot_field="SECURE_BOOT_EN", + secure_boot_expected=None, + crypt_cnt_expect=None, + flash_key_purpose_match=None, + secure_boot_key_purpose_match=None, + key_read_disable_field="RD_DIS", + ) + flash_cfg = FlashPrivateConfig( + chip=os.getenv("FACTORY_FLASH_CHIP", "esp32s3"), + baud=str(DEFAULT_BAUD), + bin_dir=Path("bin"), + layout=[], + app_bin_name="", + app_partition_offset="0x0", + flash_args={ + "flash_mode": "dio", + "flash_freq": "80m", + "flash_size": "keep", + "before": "default-reset", + "after": "no-reset", + }, + device_check=device_check, + ui={}, + ) + return flash_cfg + + def _apply_private_config(self, raw_cfg: dict) -> None: + self.private_cfg = _load_flash_private_config(self.base_dir, raw_cfg) + self.private_cfg_loaded = True + + ui_cfg = self.private_cfg.ui if isinstance(self.private_cfg.ui, dict) else {} + self.ui_title = _ui_text(ui_cfg, "flash_title", self.ui_title) + self.ui_subtitle = _ui_text(ui_cfg, "flash_subtitle", self.ui_subtitle) + self.label_port = _ui_text(ui_cfg, "label_port", self.label_port) + self.label_secure = _ui_text(ui_cfg, "label_secure", self.label_secure) + self.label_flash_enc = _ui_text(ui_cfg, "label_flash_enc", self.label_flash_enc) + self.label_key = _ui_text(ui_cfg, "label_key", self.label_key) + self.label_recommend = _ui_text(ui_cfg, "label_recommend", self.label_recommend) + self.label_log = _ui_text(ui_cfg, "label_log", self.label_log) + safe_log_value = ui_cfg.get("safe_log") + if isinstance(safe_log_value, bool): + self.safe_log = safe_log_value + self.plain_text = _ui_text(ui_cfg, "flash_mode_plain", self.plain_text) + self.app_text = _ui_text(ui_cfg, "flash_mode_app", self.app_text) + + if self.run_record and not self.run_record.get("firmware_version"): + self.run_record["firmware_version"] = self.private_cfg.app_bin_name + + def _ensure_private_config(self, port: str, show_dialog: bool) -> bool: + if self.private_cfg_loaded: + return True + if self.private_cfg_path is None or not self.private_cfg_path.exists(): + if show_dialog: + self._notify("error", "缺少配置", "未找到私有配置文件。") + return False + try: + raw_cfg = load_private_config_from_path(self.private_cfg_path) + except EncryptedConfigError: + self.log("[错误] 检测到加密私有配置。当前仅支持明文配置文件。") + if show_dialog: + self._notify( + "error", + "不支持加密配置", + "当前仅支持明文 conf/factory_private.json。", + ) + return False + except (PermissionError, ValueError, FileNotFoundError) as exc: + self.log(f"[错误] 私有配置加载失败:{exc}") + if show_dialog: + self._notify("error", "配置加载失败", str(exc)) + return False + + self._apply_private_config(raw_cfg) + self._refresh_key_status() + if hasattr(self, "root"): + self.root.after(0, lambda: self.root.title(self.ui_title)) + return True + + def _refresh_mode_status(self) -> None: + status = "运行模式:本地离线" + self.root.after(0, lambda: self.mode_status_var.set(status)) + + def _read_chip_mac(self, port: str) -> Optional[str]: + if not port: + return None + candidates: list[Optional[str]] = [] + if self.private_cfg_loaded: + candidates.append(self.private_cfg.chip) + candidates.extend(["auto", None]) + tried: set[Optional[str]] = set() + for chip in candidates: + if chip in tried: + continue + tried.add(chip) + if chip: + args = ["--chip", chip, "--port", port, "read_mac"] + else: + args = ["--port", port, "read_mac"] + code, output = self.run_tool("esptool", args, stream=False) + if code != 0: + continue + mac = extract_mac_from_output(output) + if mac: + return mac + return None + + def _refresh_key_status(self) -> None: + status = "配置签名:已启用" + self.root.after(0, lambda: self.key_status_var.set(status)) + + def _build_ui(self) -> None: + self.style = ttk.Style(self.root) + for theme in ("clam", "vista"): + if theme in self.style.theme_names(): + self.style.theme_use(theme) + break + self.root.option_add("*Font", ("Segoe UI", 10)) + palette = { + "bg": "#eef4fb", + "panel": "#f8fbff", + "text": "#15233b", + "muted": "#5a6b85", + "accent": "#1a73e8", + "accent_soft": "#d6e7ff", + "border": "#c9d7ee", + } + self.root.configure(bg=palette["bg"]) + self.style.configure(".", background=palette["bg"], foreground=palette["text"]) + self.style.configure("TFrame", background=palette["bg"]) + self.style.configure("Root.TFrame", background=palette["bg"]) + self.style.configure("Panel.TFrame", background=palette["panel"]) + self.style.configure("TLabelframe", background=palette["panel"], bordercolor=palette["border"]) + self.style.configure("TLabelframe.Label", background=palette["panel"], foreground=palette["text"]) + self.style.configure("TLabel", background=palette["bg"], foreground=palette["text"]) + self.style.configure("Root.TLabel", background=palette["bg"], foreground=palette["text"]) + self.style.configure("Panel.TLabel", background=palette["panel"], foreground=palette["text"]) + self.style.configure( + "Header.TLabel", + font=("Segoe UI", 14, "bold"), + foreground=palette["accent"], + background=palette["bg"], + ) + self.style.configure( + "SubHeader.TLabel", + font=("Segoe UI", 9), + foreground=palette["muted"], + background=palette["bg"], + ) + self.style.configure( + "Step.TLabel", + font=("Segoe UI", 9), + foreground=palette["muted"], + background=palette["bg"], + ) + self.style.configure( + "StepActive.TLabel", + font=("Segoe UI", 9, "bold"), + foreground=palette["accent"], + background=palette["bg"], + ) + self.style.configure( + "Action.TButton", + padding=(14, 6), + font=("Segoe UI", 10, "bold"), + foreground=palette["text"], + background=palette["accent_soft"], + ) + self.style.map( + "Action.TButton", + background=[("active", "#bfd7ff"), ("pressed", "#a8c8ff")], + ) + self.style.configure("TButton", padding=(10, 4), background=palette["panel"]) + self.style.map("TButton", background=[("active", "#e6f0ff")]) + self.style.configure("Disabled.TButton", foreground="#777777") + self.style.configure("Section.TLabelframe.Label", font=("Segoe UI", 10, "bold")) + self.style.configure("TEntry", fieldbackground=palette["panel"]) + + frame = ttk.Frame(self.root, padding=14, style="Root.TFrame") + frame.pack(fill="both", expand=True) + + header = ttk.Frame(frame, style="Root.TFrame") + header.pack(fill="x", pady=(0, 8)) + ttk.Label( + header, + text=self.ui_title, + style="Header.TLabel", + ).pack(anchor="w") + ttk.Label( + header, + text=self.ui_subtitle, + style="SubHeader.TLabel", + ).pack(anchor="w", pady=(2, 0)) + + port_section = ttk.LabelFrame(frame, text="1. 串口连接", padding=10, style="Section.TLabelframe") + port_section.pack(fill="x", pady=(0, 8)) + port_row = ttk.Frame(port_section, style="Panel.TFrame") + port_row.pack(fill="x") + ttk.Label(port_row, text="串口:", style="Panel.TLabel").pack(side="left") + self.port_entry = ttk.Entry(port_row, textvariable=self.port_var, width=18) + self.port_entry.pack(side="left", padx=(6, 12)) + self.detect_btn = ttk.Button(port_row, text="刷新串口", command=self.on_auto_detect) + self.detect_btn.pack(side="left", padx=(0, 12)) + ttk.Label( + port_row, + text="提示:开始烧录时按住 BOOT,点一下 RESET。", + style="Panel.TLabel", + ).pack(side="left", padx=(10, 0)) + + status_row = ttk.Frame(port_section, style="Panel.TFrame") + status_row.pack(fill="x", pady=(6, 0)) + ttk.Label(status_row, textvariable=self.mode_status_var, style="Panel.TLabel").pack( + side="left" + ) + ttk.Label(status_row, textvariable=self.key_status_var, style="Panel.TLabel").pack( + side="left", padx=(12, 0) + ) + + detect_section = ttk.LabelFrame(frame, text="2. 一键流程", padding=10, style="Section.TLabelframe") + detect_section.pack(fill="x", pady=(0, 8)) + detect_row = ttk.Frame(detect_section, style="Panel.TFrame") + detect_row.pack(fill="x") + self.start_btn = ttk.Button( + detect_row, text="开始", command=self.on_start, style="Action.TButton" + ) + self.start_btn.pack(side="left") + self.mode_label = ttk.Label( + detect_row, + textvariable=self.recommend_var, + foreground="#1a73e8", + font=("Segoe UI", 10, "bold"), + wraplength=520, + justify="left", + style="Panel.TLabel", + ) + self.mode_label.pack(side="left", padx=(10, 0), fill="x", expand=True) + + hint_frame = ttk.Frame(detect_section, style="Panel.TFrame") + hint_frame.pack(fill="x", pady=(6, 0)) + ttk.Label(hint_frame, text="提示:", style="Panel.TLabel").pack(side="left", anchor="n") + self.hint_label = ttk.Label( + hint_frame, + textvariable=self.hint_var, + wraplength=680, + justify="left", + foreground="#5a6b85", + style="Panel.TLabel", + ) + self.hint_label.pack(side="left", fill="x", expand=True) + + log_section = ttk.LabelFrame( + frame, text=self.label_log, padding=10, style="Section.TLabelframe" + ) + log_section.pack(fill="both", expand=True) + log_actions = ttk.Frame(log_section, style="Panel.TFrame") + log_actions.pack(fill="x", pady=(0, 6)) + self.clear_btn = ttk.Button(log_actions, text="清空日志", command=self.clear_log) + self.clear_btn.pack(side="right") + self.copy_btn = ttk.Button(log_actions, text="复制日志", command=self.copy_log) + self.copy_btn.pack(side="right", padx=(0, 8)) + + self.log_box = scrolledtext.ScrolledText( + log_section, + height=16, + wrap="word", + background="#ffffff", + foreground="#1f2a33", + insertbackground="#1f2a33", + ) + self.log_box.pack(fill="both", expand=True) + self.log_box.configure(state="disabled", font=("Consolas", 10)) + + status_frame = ttk.Frame(frame, style="Root.TFrame") + status_frame.pack(fill="x", pady=(8, 0)) + ttk.Label(status_frame, text="提示:", style="Root.TLabel").pack(side="left") + self.status_label = ttk.Label(status_frame, textvariable=self.status_var, style="Root.TLabel") + self.status_label.pack(side="left", padx=(6, 0)) + self.progress = ttk.Progressbar( + status_frame, + mode="indeterminate", + length=140, + maximum=100, + variable=self.progress_value, + ) + self.progress.pack(side="right", padx=(8, 0)) + self.progress.pack_forget() + self.update_flash_buttons() + + def _notify(self, kind: str, title: str, message: str) -> None: + def _show() -> None: + if kind == "info": + messagebox.showinfo(title, message) + return + if kind == "warn": + messagebox.showwarning(title, message) + return + messagebox.showerror(title, message) + + self.root.after(0, _show) + + def log(self, message: str, timestamp: bool = True) -> None: + safe_message = _redact_sensitive(message) + + def _append() -> None: + self.log_box.configure(state="normal") + prefix = "" + if timestamp and safe_message: + prefix = f"[{datetime.now().strftime('%H:%M:%S')}] " + line = prefix + safe_message if safe_message else "" + self.log_box.insert("end", line + "\n") + self.log_box.see("end") + self.log_box.configure(state="disabled") + + self.root.after(0, _append) + + def log_section(self, title: str) -> None: + self.log("", timestamp=False) + self.log(title, timestamp=False) + self.log("-" * len(title), timestamp=False) + + def _resolve_operator(self) -> str: + for key in ("FACTORY_OPERATOR", "OPERATOR_ID", "OPERATOR", "USERNAME", "USER"): + value = os.getenv(key) + if isinstance(value, str) and value.strip(): + return value.strip() + try: + return getpass.getuser() + except Exception: + return "unknown" + + def _log_dir(self) -> Path: + env_path = os.getenv("FACTORY_LOG_DIR") + if isinstance(env_path, str) and env_path.strip(): + return Path(env_path).expanduser() + if os.name == "nt": + base = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") + if base: + return Path(base) / "TalkingQFactory" / "logs" + return Path.home() / "AppData" / "Local" / "TalkingQFactory" / "logs" + if sys.platform == "darwin": + return Path.home() / "Library" / "Logs" / "TalkingQFactory" + return Path.home() / ".cache" / "talkingq-factory" + + def _begin_run_record(self, port: str) -> None: + firmware_version = os.getenv("FACTORY_FIRMWARE_VERSION") or os.getenv("FIRMWARE_VERSION") + if isinstance(firmware_version, str) and firmware_version.strip(): + firmware_version = firmware_version.strip() + else: + firmware_version = self.private_cfg.app_bin_name + run_id = uuid.uuid4().hex[:8] + self.run_record = { + "tool": "factory_flash_gui", + "run_id": run_id, + "started_at": _now_iso(), + "operator": self._resolve_operator(), + "host": platform.node(), + "port": port, + "device_id": None, + "firmware_version": firmware_version, + "steps": {}, + } + + def _record_error(self, stage: str, message: str, code: Optional[str] = None) -> None: + if not self.run_record: + return + self.run_record["error"] = { + "stage": stage, + "message": message, + "code": code, + } + + def _record_step_result( + self, + step: str, + start_ts: float, + status: str, + details: Optional[dict] = None, + ) -> None: + if not self.run_record: + return + started_at = datetime.fromtimestamp(start_ts, tz=timezone.utc).isoformat() + payload = { + "started_at": started_at, + "duration_s": int(max(0.0, time.time() - start_ts)), + "status": status, + } + if details: + payload.update(details) + self.run_record.setdefault("steps", {})[step] = payload + + def _sanitize_run_record(self, record: dict) -> dict: + safe: dict = { + "tool": record.get("tool"), + "run_id": record.get("run_id"), + "device_id": record.get("device_id"), + "started_at": record.get("started_at"), + "ended_at": record.get("ended_at"), + "duration_s": record.get("duration_s"), + "success": record.get("success"), + } + error = record.get("error") + if isinstance(error, dict): + safe_error: dict = {} + for key in ("stage", "message", "code"): + value = error.get(key) + if isinstance(value, str): + safe_error[key] = _redact_sensitive(value) + elif value is not None: + safe_error[key] = value + if safe_error: + safe["error"] = safe_error + steps = {} + raw_steps = record.get("steps", {}) + if isinstance(raw_steps, dict): + for step, payload in raw_steps.items(): + if not isinstance(payload, dict): + continue + step_safe: dict = {} + for key in ("status", "duration_s", "reason", "attempts", "errors"): + if key not in payload: + continue + value = payload.get(key) + if isinstance(value, str): + step_safe[key] = _redact_sensitive(value) + elif isinstance(value, list): + step_safe[key] = [ + _redact_sensitive(item) if isinstance(item, str) else item + for item in value + ] + else: + step_safe[key] = value + if step_safe: + steps[step] = step_safe + if steps: + safe["steps"] = steps + return safe + + def _write_run_record(self, record: dict) -> Optional[Path]: + log_dir = self._log_dir() + try: + log_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + self.log(f"[警告] 无法创建记录目录: {exc}") + return None + record_id = record.get("device_id") or "unknown" + success = record.get("success") + if success is True: + status_tag = "ok" + elif success is False: + status_tag = "failed" + else: + status_tag = "unknown" + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{ts}_{_sanitize_filename(str(record_id))}_{status_tag}.json" + path = log_dir / filename + try: + path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + except OSError as exc: + self.log(f"[警告] 记录落盘失败: {exc}") + return None + return path + + def _finalize_run_record(self, success: bool) -> None: + if not self.run_record: + return + self.run_record["ended_at"] = _now_iso() + started_at = self.run_record.get("started_at") + if isinstance(started_at, str): + try: + start_dt = datetime.fromisoformat(started_at) + self.run_record["duration_s"] = int((datetime.now(timezone.utc) - start_dt).total_seconds()) + except ValueError: + pass + if self.current_device_id and not self.run_record.get("device_id"): + self.run_record["device_id"] = self.current_device_id + if self.device_id_hint and not self.run_record.get("device_id"): + self.run_record["device_id"] = self.device_id_hint + self.run_record["success"] = bool(success) + safe_record = self._sanitize_run_record(self.run_record) + path = self._write_run_record(safe_record) + if path: + if self.safe_log: + device_id = safe_record.get("device_id") + if isinstance(device_id, str) and device_id: + self.log(f"[OK] 产测记录已保存 (device_id={device_id})。") + else: + self.log("[OK] 产测记录已保存。") + else: + self.log(f"[OK] 产测记录已保存: {path}") + self.run_record = None + + def _on_flash_output(self, line: str) -> None: + self._parse_flash_progress(line) + if self.safe_log: + return + if line: + self.log(line) + + def _parse_flash_progress(self, line: str) -> None: + if not line: + return + if "Wrote " in line and "bytes" in line: + self.set_progress(100.0) + if self.active_mode_label: + self.set_status(f"{self.active_mode_label}进行中... 100%") + return + match = re.search(r"(\d+(?:\.\d+)?)%", line) + if not match: + return + try: + percent = float(match.group(1)) + except ValueError: + return + self.set_progress(percent) + if self.active_mode_label: + self.set_status(f"{self.active_mode_label}进行中... {percent:.0f}%") + + def set_status(self, message: str) -> None: + self.root.after(0, lambda: self.status_var.set(message)) + + def set_status_tone(self, tone: str) -> None: + palette = { + "info": "#1a73e8", + "success": "#0b8a3c", + "warn": "#c77c02", + "error": "#c0362c", + } + color = palette.get(tone, "#000") + self.root.after(0, lambda: self.status_label.configure(foreground=color)) + + def set_busy_indicator(self, busy: bool, mode: str = "indeterminate") -> None: + def _apply() -> None: + if busy: + self.progress.pack(side="right", padx=(8, 0)) + if mode == "determinate": + self.progress.configure(mode="determinate") + self.progress_value.set(0.0) + else: + self.progress.configure(mode="indeterminate") + self.progress.start(8) + return + self.progress.stop() + self.progress.pack_forget() + + self.root.after(0, _apply) + + def set_progress(self, value: float) -> None: + def _apply() -> None: + self.progress_value.set(max(0.0, min(100.0, value))) + + self.root.after(0, _apply) + + def clear_log(self, update_status: bool = True) -> None: + self.log_box.configure(state="normal") + self.log_box.delete("1.0", "end") + self.log_box.configure(state="disabled") + if update_status: + self.set_status("日志已清空。") + self.set_status_tone("info") + + def copy_log(self) -> None: + content = self.log_box.get("1.0", "end").strip() + self.root.clipboard_clear() + if content: + self.root.clipboard_append(content) + self.set_status("日志已复制到剪贴板。") + self.set_status_tone("success") + else: + self.set_status("日志为空。") + self.set_status_tone("warn") + + def set_hint_text(self, text: str, tone: str = "info") -> None: + palette = { + "info": "#555555", + "success": "#0b8a3c", + "warn": "#c77c02", + "error": "#c0362c", + } + color = palette.get(tone, "#555555") + + def _apply() -> None: + self.hint_var.set(text) + self.hint_label.configure(foreground=color) + + self.root.after(0, _apply) + + def _normalize_port(self, port: Optional[str]) -> str: + if not port: + return "" + return port.strip().upper() + + def _on_port_change(self, *_args) -> None: + port = self._normalize_port(self.port_var.get()) + if port == self._last_port_value: + return + self._last_port_value = port + if self.is_busy: + self.update_flash_buttons() + return + + if not port: + self.checked_port = None + self.summary_attempted = False + self.summary_ok = False + self.last_rec_mode = None + self.secure_boot_blocked = False + self.secure_boot_enabled = None + self.boot_chain_present = None + self.boot_chain_probe_reason = None + self.download_mode_disabled = None + self.secure_download_enabled = None + self.manual_encrypt_disabled = None + self.device_id_hint = None + self.recommend_var.set("请选择串口后点击开始。") + self.set_hint_text("", tone="info") + self.card_port_var.set("未选择") + self.card_secure_boot_var.set("未检测") + self.card_flash_enc_var.set("未检测") + self.card_recommend_var.set("待检测") + self.update_flash_buttons() + return + + if self.summary_attempted: + self.summary_attempted = False + self.summary_ok = False + self.last_rec_mode = None + self.secure_boot_blocked = False + self.secure_boot_enabled = None + self.boot_chain_present = None + self.boot_chain_probe_reason = None + self.download_mode_disabled = None + self.secure_download_enabled = None + self.manual_encrypt_disabled = None + self.device_id_hint = None + self.recommend_var.set("串口已变更,请点击开始重新检测。") + self.set_hint_text("检测结果仅对当前串口有效。", tone="warn") + elif not self.summary_attempted: + self.recommend_var.set("准备开始:点击开始自动检测并执行流程。") + self.set_hint_text("", tone="info") + + self.card_port_var.set(port) + self.card_secure_boot_var.set("待检测") + self.card_flash_enc_var.set("待检测") + self.card_recommend_var.set("请先设备检测") + self.update_flash_buttons() + + def update_flash_buttons(self) -> None: + self.root.after(0, self._apply_flash_buttons) + + def _apply_flash_buttons(self) -> None: + busy = self.is_busy + + self.detect_btn.state(["disabled"] if busy else ["!disabled"]) + self.port_entry.configure(state="disabled" if busy else "normal") + + start_state = ["!disabled"] + if busy: + start_state = ["disabled"] + self.start_btn.state(start_state) + + def disable_buttons(self, progress_mode: str = "indeterminate") -> None: + self.is_busy = True + self.set_busy_indicator(True, mode=progress_mode) + self.update_flash_buttons() + + def enable_buttons(self) -> None: + self.is_busy = False + self.set_busy_indicator(False) + self.update_flash_buttons() + self.active_mode_label = "" + + def on_auto_detect(self) -> None: + port = self._auto_detect_port(show_dialog=True) + if port: + self.port_var.set(port) + + def on_start(self) -> None: + self.start_task(self.flow_task, progress_mode="determinate") + + def start_task(self, func, progress_mode: str = "indeterminate") -> None: + if self.worker and self.worker.is_alive(): + return + self.disable_buttons(progress_mode=progress_mode) + self.set_status("处理中...") + self.set_status_tone("info") + self.worker = threading.Thread(target=self._run_wrapper, args=(func,), daemon=True) + self.worker.start() + + def _run_wrapper(self, func) -> None: + try: + func() + finally: + self.root.after(0, self.enable_buttons) + if self.suppress_ready_status: + self.suppress_ready_status = False + return + self.set_status("就绪。") + self.set_status_tone("info") + + def prepare_secure_entries( + self, entries: Iterable[Tuple[object, Path]] + ) -> Optional[list[FlashSource]]: + missing: list[Path] = [] + sources: list[Tuple[int, Path, Path]] = [] + + for offset, path in entries: + source = Path(str(path) + ".enc") + try: + offset_int = _parse_offset(offset) + except ValueError as exc: + self.log("[错误] 固件偏移配置无效。") + if not self.safe_log: + self.log(f" - {exc}") + self._notify("error", "偏移无效", "固件偏移配置无效,请查看日志。") + return None + sources.append((offset_int, path, source)) + if not source.exists(): + missing.append(source) + + if missing: + self.log("[错误] 缺少必要固件文件。") + if not self.safe_log: + for item in missing: + self.log(f" - {_display_path(self.base_dir, item)}") + self._notify("error", "文件缺失", "缺少必要固件文件,请查看日志。") + return None + + try: + load_firmware_decrypt_key() + except FirmwareEnvelopeError as exc: + self.log("[错误] .enc 固件解密密钥不可用。") + if not self.safe_log: + self.log(f" - {exc}") + self._notify("error", "缺少解密密钥", str(exc)) + return None + + resolved: list[FlashSource] = [] + for offset_int, orig, source in sources: + try: + verify_file_signature(source) + except SignatureError as exc: + self.log("[错误] 固件校验失败。") + if not self.safe_log: + self.log(f" - {_display_path(self.base_dir, source)}") + self.log(f" - {exc}") + self._notify("error", "文件校验失败", "文件校验失败,请查看日志。") + return None + resolved.append( + FlashSource( + offset=offset_int, + path=source, + name=orig.name, + ) + ) + + return resolved + + def _is_force_bootloader_entry(self, entry: FlashSource) -> bool: + name = entry.name.strip().lower() + return entry.offset == 0x0 and "bootloader" in name and name.endswith(".bin") + + @staticmethod + def _normalize_chip_name(value: object) -> str: + text = str(value or "").strip().lower() + return re.sub(r"[^0-9a-z]", "", text) + + @staticmethod + def _format_rev_full(rev: int) -> str: + if rev < 0: + return "unknown" + return f"v{rev // 100}.{rev % 100}" + + def _guard_force_bootloader_write( + self, + esp: Any, + entry: FlashSource, + payload: bytes, + *, + encrypt: bool, + ) -> None: + from esptool.bin_image import LoadFirmwareImage + + if not self._is_force_bootloader_entry(entry): + raise RuntimeError( + f"force 风险拦截:仅允许 bootloader@0x0,当前条目为 {entry.name} @ {hex(entry.offset)}" + ) + + expected_chip = self._normalize_chip_name(self.private_cfg.chip) + chip_name = str(getattr(esp, "CHIP_NAME", "") or "") + actual_chip = self._normalize_chip_name(chip_name) + if expected_chip and expected_chip != "auto" and actual_chip and expected_chip != actual_chip: + raise RuntimeError( + "force 风险拦截:设备芯片与配置不一致," + f"cfg={self.private_cfg.chip}, detected={chip_name or 'unknown'}" + ) + + try: + image = LoadFirmwareImage(chip_name or self.private_cfg.chip, payload) + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + f"force 风险拦截:无法解析 bootloader 镜像头({entry.name}),已拒绝写入。" + ) from exc + + image_chip_id = getattr(image, "chip_id", None) + target_chip_id = getattr(esp, "IMAGE_CHIP_ID", None) + if image_chip_id != target_chip_id: + raise RuntimeError( + "force 风险拦截:bootloader 芯片 ID 与目标芯片不匹配," + f"image={image_chip_id}, target={target_chip_id}" + ) + + def _to_int(value: object, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + image_min_rev = _to_int(getattr(image, "min_rev", 0), 0) + image_min_rev_full = _to_int(getattr(image, "min_rev_full", 0), 0) + image_max_rev_full = _to_int(getattr(image, "max_rev_full", 0), 0) + + if image_max_rev_full == 0: + use_rev_full_fields = False + elif image_max_rev_full == 65535: + use_rev_full_fields = not (image_min_rev_full == 0 and image_min_rev != 0) + else: + use_rev_full_fields = True + + if use_rev_full_fields: + try: + chip_rev_full = _to_int(getattr(esp, "get_chip_revision")(), -1) + except Exception as exc: # noqa: BLE001 + raise RuntimeError("force 风险拦截:无法读取芯片 revision 信息。") from exc + if chip_rev_full < image_min_rev_full or chip_rev_full > image_max_rev_full: + upper = ( + "max rev not set" + if image_max_rev_full == 65535 + else self._format_rev_full(image_max_rev_full) + ) + raise RuntimeError( + "force 风险拦截:bootloader revision 约束不满足," + f"requires=[{self._format_rev_full(image_min_rev_full)} - {upper}], " + f"chip={self._format_rev_full(chip_rev_full)}" + ) + else: + try: + if chip_name == "ESP32-C3": + chip_rev = _to_int(getattr(esp, "get_minor_chip_version")(), -1) + else: + chip_rev = _to_int(getattr(esp, "get_major_chip_version")(), -1) + except Exception as exc: # noqa: BLE001 + raise RuntimeError("force 风险拦截:无法读取芯片 revision 信息。") from exc + if chip_rev < image_min_rev: + raise RuntimeError( + "force 风险拦截:bootloader 最小 revision 要求不满足," + f"requires={image_min_rev}, chip={chip_rev}" + ) + + if not encrypt and chip_name != "ESP8266": + try: + flash_encryption_enabled = bool(getattr(esp, "get_flash_encryption_enabled")()) + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + "force 风险拦截:无法确认 Flash Encryption 状态,已拒绝明文 force 写入。" + ) from exc + if flash_encryption_enabled: + raise RuntimeError( + "force 风险拦截:检测到 Flash Encryption 已启用,拒绝明文 force 写入。" + ) + + secure_download_mode = bool(getattr(esp, "secure_download_mode", False)) + if chip_name != "ESP32" and secure_download_mode: + try: + security_info = getattr(esp, "get_security_info")() + flash_crypt_cnt = _to_int( + security_info.get("flash_crypt_cnt") if isinstance(security_info, dict) else 0 + ) + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + "force 风险拦截:无法读取 secure download 状态下的 flash_crypt_cnt。" + ) from exc + if bin(flash_crypt_cnt).count("1") & 1 != 0: + raise RuntimeError( + "force 风险拦截:检测到加密状态,拒绝在 secure download 模式下明文 force 写入。" + ) + + def ensure_files_exist(self, paths: Iterable[Path]) -> bool: + missing = [str(p) for p in paths if not p.exists()] + if missing: + self.log("[错误] 缺少必要文件。") + if not self.safe_log: + for item in missing: + self.log(f" - {_display_path(self.base_dir, Path(item))}") + self._notify("error", "文件缺失", "缺少必要文件,请查看日志。") + return False + return True + + def _auto_detect_port(self, show_dialog: bool) -> Optional[str]: + ports = list_serial_ports(self.python_cmd, show_dialog) + if not ports: + if show_dialog: + self._notify("warn", "未检测到串口", "未检测到可用串口,请手动输入。") + return None + selected = pick_preferred_port(ports) + if len(ports) > 1: + if self.safe_log: + self.log("[提示] 检测到多个串口,已自动选择。") + else: + self.log(f"[提示] 检测到多个串口: {', '.join(ports)}") + self.log(f"[提示] 自动选择: {selected}") + if show_dialog: + message = "检测到多个串口,已自动选择。" + if not self.safe_log: + message = f"检测到多个串口:\n{', '.join(ports)}\n\n已自动选择: {selected}" + self._notify("info", "检测到多个串口", message) + return selected + + def _auto_set_port(self, initial: bool) -> None: + if not initial and self.port_var.get().strip(): + return + port = self._auto_detect_port(show_dialog=False) + if port: + self.port_var.set(port) + + def preflight_check(self) -> None: + if not self.ensure_tools_available(): + self.set_status("缺少工具。") + self.set_status_tone("error") + return + self.log("[OK] 检测到工具,准备就绪。") + self._refresh_mode_status() + self._refresh_key_status() + self.set_status("就绪。") + self.set_status_tone("success") + self.update_flash_buttons() + + def flow_task(self) -> None: + self.suppress_ready_status = True + self.current_device_id = None + self.last_flash_app_only = None + self._post_flash_reset_done = False + self._begin_run_record("") + success = False + try: + port = self.port_var.get().strip() + if not port: + port = self._auto_detect_port(show_dialog=False) or "" + if not port: + self.set_status("未检测到串口。") + self.set_status_tone("warn") + self._notify("warn", "未检测到串口", "未检测到可用串口,请手动输入。") + self._record_error("preflight", "no_port") + return + self.port_var.set(port) + if self.run_record is not None: + self.run_record["port"] = port + + if not self._ensure_private_config(port, show_dialog=True): + self.set_status("配置加载失败。") + self.set_status_tone("error") + self._record_error("preflight", "private_config_missing") + return + + self.set_progress(0.0) + self.recommend_var.set("步骤 1/3:设备检测中...") + if not self.device_check_task(): + self._record_error("device_check", "failed") + return + + flash_start_ts = time.time() + mode = self._decide_flash_mode() + if mode is None: + reason = self.last_flash_block_reason or "mode_unknown" + self._record_error("flash", reason) + self._record_step_result( + "flash", + flash_start_ts, + "failed", + {"reason": reason}, + ) + return + encrypt, app_only, mode_label = mode + self.last_flash_app_only = app_only + + self.recommend_var.set(f"步骤 2/3:{mode_label}烧录中...") + if not self.flash_task(encrypt=encrypt, app_only=app_only): + self._record_error("flash", "failed") + return + + self.recommend_var.set("步骤 3/3:安全复检中...") + if not self.post_flash_check_task(port): + self._record_error("post_flash_check", "failed") + return + self._attempt_reset(port) + time.sleep(1.0) + + self.recommend_var.set("完成") + self.set_status("流程完成。") + self.set_status_tone("success") + self._notify("info", "完成", "烧录流程完成。") + success = True + finally: + self._finalize_run_record(success) + + def _decide_flash_mode(self) -> Optional[Tuple[bool, bool, str]]: + self.last_flash_block_reason = None + if not self.summary_ok: + self._notify("error", "检测失败", "设备检测未通过,无法继续。") + self.last_flash_block_reason = "summary_failed" + return None + if self.download_mode_disabled is True: + self._notify("error", "下载模式已禁用", "ROM 下载模式已禁用,无法串口烧录。") + self.last_flash_block_reason = "download_mode_disabled" + return None + if self.secure_download_enabled is True: + self._notify( + "error", + "安全下载模式", + "检测到安全下载模式已启用,当前工具不支持安全下载协议。", + ) + self.last_flash_block_reason = "secure_download_enabled" + return None + encrypt: Optional[bool] = None + if self.last_rec_mode == "1": + encrypt = False + elif self.last_rec_mode == "2": + encrypt = True + if encrypt is None: + self._notify("error", "模式未知", "无法判断烧录模式,请检查设备状态。") + self.last_flash_block_reason = "rec_mode_unknown" + return None + if encrypt and self.manual_encrypt_disabled is True: + self._notify( + "error", + "串口加密受限", + "检测到禁用串口手动加密,无法使用当前工具进行加密写入。", + ) + self.last_flash_block_reason = "manual_encrypt_disabled" + return None + if self.secure_boot_enabled is True: + if self.boot_chain_present is False: + if encrypt: + return True, False, "加密全量(初次写入)" + return False, False, f"{self.plain_text}(初次写入)" + mode_label = self.app_text if encrypt else f"{self.app_text}(不加密)" + return encrypt, True, mode_label + if encrypt is False: + return False, False, f"{self.plain_text}" + if encrypt is True: + return True, False, "加密全量" + self._notify("error", "模式未知", "无法判断烧录模式,请检查设备状态。") + return None + + def _read_efuse_summary(self, port: str) -> Tuple[int, str, Optional[dict]]: + args = [ + "--chip", + self.private_cfg.chip, + "--port", + port, + "summary", + "--format", + "json", + ] + code, output = self.run_tool("espefuse", args, stream=False) + summary_json = _extract_json_blob(output) if code == 0 else None + return code, output, summary_json + + def _log_efuse_summary(self, output: str) -> None: + if not output: + return + self.log_section("eFuse Summary") + if self.safe_log: + self.log("已获取(内容已脱敏)。", timestamp=False) + return + for line in output.splitlines(): + self.log(line, timestamp=False) + + def device_check_task(self) -> bool: + start_ts = time.time() + port = self.port_var.get().strip() + if not port: + port = self._auto_detect_port(show_dialog=False) or "" + if not port: + self._notify("warn", "未检测到串口", "未检测到可用串口,请手动输入。") + self._record_step_result( + "device_check", + start_ts, + "failed", + {"reason": "no_port"}, + ) + return False + self.port_var.set(port) + if not self._ensure_private_config(port, show_dialog=True): + self._record_step_result( + "device_check", + start_ts, + "failed", + {"reason": "private_config_missing"}, + ) + return False + if not self.ensure_tools_available(): + self._record_step_result( + "device_check", + start_ts, + "failed", + {"reason": "tools_missing"}, + ) + return False + + self.set_status("正在检测设备...") + self.set_status_tone("info") + self.log_section("设备检测") + if not self.safe_log: + self.log(f"串口: {port}") + self.recommend_var.set("正在检测设备...") + self.set_hint_text("", tone="info") + + code, summary_output, summary_json = self._read_efuse_summary(port) + if summary_output: + self._log_efuse_summary(summary_output) + + self.summary_attempted = True + if code != 0 or summary_json is None: + if code != 0: + self.log("[警告] 设备检测失败。") + else: + self.log("[警告] 设备检测失败:JSON 解析失败。") + self.set_status("检测失败。") + self.set_status_tone("warn") + self.summary_ok = False + self.last_rec_mode = None + self.secure_boot_blocked = False + self.secure_boot_enabled = None + self.boot_chain_present = None + self.boot_chain_probe_reason = None + self.download_mode_disabled = None + self.secure_download_enabled = None + self.manual_encrypt_disabled = None + self.checked_port = None + self.card_secure_boot_var.set("检测失败") + self.card_flash_enc_var.set("检测失败") + self.card_recommend_var.set("请检查连接后重试") + hints = collect_error_hints(summary_output) + # 不展示检测结果,只提示信息 + self.recommend_var.set("检测失败,请查看提示后重试。") + self.set_hint_text("\n".join(f"- {hint}" for hint in hints), tone="warn") + self.log("操作指引:", timestamp=False) + for hint in hints: + self.log(f" - {hint}", timestamp=False) + self.update_flash_buttons() + self.log("流程已停止,请重试设备检测。") + self._record_step_result( + "device_check", + start_ts, + "failed", + { + "reason": "summary_json_failed" if summary_json is None else "summary_failed", + "hints": hints, + "port": port, + }, + ) + return False + rec_mode, rec_reason = recommend_mode_from_json( + summary_json, self.private_cfg.device_check.crypt_cnt_fields + ) + self.summary_ok = True + self.last_rec_mode = rec_mode + self.secure_boot_blocked = False + self.secure_boot_enabled = get_efuse_flag( + summary_json, self.private_cfg.device_check.secure_boot_field + ) + self.download_mode_disabled = get_efuse_flag( + summary_json, "DIS_DOWNLOAD_MODE", "UART_DOWNLOAD_DIS" + ) + self.secure_download_enabled = get_efuse_flag( + summary_json, "ENABLE_SECURITY_DOWNLOAD", "SECURE_DOWNLOAD_MODE" + ) + self.manual_encrypt_disabled = get_efuse_flag( + summary_json, "DIS_DOWNLOAD_MANUAL_ENCRYPT" + ) + if self.secure_boot_enabled is True and rec_mode == "1": + self.boot_chain_present, self.boot_chain_probe_reason = self._probe_boot_chain_header(port) + elif self.secure_boot_enabled is True and rec_mode == "2": + # When flash encryption is enabled, raw flash reads are ciphertext and + # cannot be used to infer plaintext image headers at 0x0. + self.boot_chain_present = True + self.boot_chain_probe_reason = "skip_probe_flash_encryption_enabled" + else: + self.boot_chain_present = None + self.boot_chain_probe_reason = "skip_probe_not_applicable" + self.checked_port = port + mac = self._read_chip_mac(port) + if mac: + self.device_id_hint = mac + if self.run_record and not self.run_record.get("device_id"): + self.run_record["device_id"] = mac + flash_enc_status = "未知" + if rec_mode == "1": + flash_enc_status = "未启用" + elif rec_mode == "2": + flash_enc_status = "已启用" + self.card_flash_enc_var.set(flash_enc_status) + + if self.secure_boot_enabled is True: + self.card_secure_boot_var.set("已启用") + elif self.secure_boot_enabled is False: + self.card_secure_boot_var.set("未启用") + else: + self.card_secure_boot_var.set("未知") + + boot_chain_missing = self.boot_chain_present is False + + if self.secure_boot_enabled: + if boot_chain_missing: + if rec_mode == "1": + recommend_text = f"下一步:{self.plain_text}(初次全量)" + elif rec_mode == "2": + recommend_text = "检测到安全启动已启用且启动链缺失,将执行加密全量初次写入。" + else: + recommend_text = "检测到安全启动已启用且启动链缺失,请先核对设备信息。" + elif rec_mode == "1": + recommend_text = f"下一步:{self.app_text}(不加密)" + elif rec_mode == "2": + recommend_text = f"下一步:{self.app_text}(加密)" + else: + recommend_text = "请先核对设备信息" + elif rec_mode == "1": + recommend_text = f"下一步:{self.plain_text}" + elif rec_mode == "2": + recommend_text = "检测到闪存加密已启用但安全启动未启用,将执行加密全量烧录并在首启后启用安全启动。" + else: + recommend_text = "请先核对设备信息" + + if self.download_mode_disabled is True: + recommend_text = "检测到下载模式已禁用,请按工艺处理。" + elif self.secure_download_enabled is True: + recommend_text = "检测到安全下载模式已启用,请按工艺处理。" + elif self.manual_encrypt_disabled is True and rec_mode == "2": + recommend_text = "检测到禁用串口手动加密,请使用预加密固件或 OTA。" + + self.recommend_var.set(recommend_text) + self.card_recommend_var.set(recommend_text) + hint_lines = [] + hint_tone = "info" + if rec_mode == "1": + if self.secure_boot_enabled: + if boot_chain_missing: + hint_lines.append("检测到启动链缺失,将执行模式 A(全量写入)完成初次写入。") + else: + hint_lines.append("提示:安全启动已启用但闪存加密未启用,将以非加密写入方式执行模式 C。") + hint_tone = "warn" + else: + hint_lines.append("已就绪:执行模式 A(全量写入)。") + elif rec_mode == "2": + if self.secure_boot_enabled: + if boot_chain_missing: + hint_lines.append("检测到启动链缺失,将执行加密全量初次写入。") + hint_tone = "warn" + else: + hint_lines.append("已就绪:执行模式 C(加密)。") + else: + hint_lines.append("提示:将执行加密全量烧录,首启后自动启用安全启动。") + hint_tone = "warn" + else: + hint_lines.append("信息不明确:请按工艺要求核对后再选择模式。") + hint_tone = "warn" + + if self.secure_boot_enabled: + if boot_chain_missing: + hint_lines.append("检测到 flash@0x0 启动头无效/空白,将改为全量写入并在需要时使用 force。") + else: + hint_lines.append("已限制,仅允许模式 C。") + + if self.download_mode_disabled is True: + hint_lines.append("检测到下载模式已禁用,串口烧录不可用。") + hint_tone = "error" + if self.secure_download_enabled is True: + hint_lines.append("检测到安全下载模式已启用,当前工具不支持安全下载。") + if hint_tone != "error": + hint_tone = "warn" + if self.manual_encrypt_disabled is True: + if rec_mode == "2": + hint_lines.append("检测到禁用串口手动加密,无法进行加密写入。") + hint_tone = "error" + else: + hint_lines.append("检测到禁用串口手动加密(仅影响加密写入)。") + if hint_tone != "error": + hint_tone = "warn" + + self.set_hint_text("\n".join(hint_lines), tone=hint_tone) + self.log("-" * 46, timestamp=False) + self.log("设备检测完成,已自动确定烧录模式。") + self.set_status("检测完成。") + self.set_status_tone("success") + self.update_flash_buttons() + self._record_step_result( + "device_check", + start_ts, + "ok", + { + "port": port, + "recommend_mode": rec_mode, + "recommend_reason": rec_reason, + "secure_boot_enabled": self.secure_boot_enabled, + "boot_chain_present": self.boot_chain_present, + "boot_chain_probe_reason": self.boot_chain_probe_reason, + "flash_enc_status": flash_enc_status, + "download_mode_disabled": self.download_mode_disabled, + "secure_download_enabled": self.secure_download_enabled, + "manual_encrypt_disabled": self.manual_encrypt_disabled, + }, + ) + return True + + def _format_recommendation( + self, mode: Optional[str], reason: Optional[str], warn_reason: Optional[str] + ) -> str: + if warn_reason and not mode: + return f"摘要不可用({warn_reason})。" + if mode: + if mode == "1": + label = self.plain_text + elif mode == "2": + label = self.app_text + else: + label = f"模式 {mode}" + reason_suffix = f"({reason})" if reason else "" + if mode in ("1", "2"): + return f"下一步:{label}(模式 {mode}{reason_suffix})" + return f"模式提示:{mode}{reason_suffix}" + return "暂无指引。" + + def flash_task(self, encrypt: bool, app_only: bool = False) -> bool: + start_ts = time.time() + if not self.summary_attempted: + self.log("[警告] 请先完成设备检测。") + self._notify("warn", "请先设备检测", "请先完成设备检测,再开始烧录。") + self._record_step_result( + "flash", + start_ts, + "failed", + {"reason": "summary_missing"}, + ) + return False + + port = self.port_var.get().strip() + if not port: + port = self._auto_detect_port(show_dialog=False) or "" + if not port: + self._notify("warn", "未检测到串口", "未检测到可用串口,请手动输入。") + self._record_step_result( + "flash", + start_ts, + "failed", + {"reason": "no_port"}, + ) + return False + self.port_var.set(port) + if not self._ensure_private_config(port, show_dialog=True): + self._record_step_result( + "flash", + start_ts, + "failed", + {"reason": "private_config_missing"}, + ) + return False + bin_dir = select_bin_dir(self.base_dir, self.private_cfg.bin_dir) + entries = build_flash_entries( + bin_dir, + self.private_cfg.layout, + app_only, + self.private_cfg.app_partition_offset, + self.private_cfg.app_bin_name, + ) + secure_entries = self.prepare_secure_entries(entries) + if not secure_entries: + return False + self.log("固件目录已加载。") + + if app_only: + mode_label = self.app_text if encrypt else f"{self.app_text}(不加密)" + else: + mode_label = self.plain_text + self.active_mode_label = mode_label + self.suppress_ready_status = True + self.set_progress(0.0) + self.set_status(f"{mode_label}进行中... 0%") + self.set_status_tone("info") + self.log_section(f"{mode_label}烧录开始") + if not self.safe_log: + self.log(f"串口: {port} | 模式: {mode_label}") + + # Force is only allowed for secure-boot recovery of a missing boot chain. + force_full_flash = bool( + self.secure_boot_enabled is True and not app_only and self.boot_chain_present is False + ) + if force_full_flash: + low_entries = [entry for entry in secure_entries if entry.offset < 0x8000] + force_entries = [entry for entry in low_entries if self._is_force_bootloader_entry(entry)] + if len(low_entries) != 1 or len(force_entries) != 1: + self.log("[错误] force 安全策略校验失败:仅允许 bootloader@0x0 使用 force。") + self._record_step_result( + "flash", + start_ts, + "failed", + { + "reason": "force_policy_mismatch", + "entries": [ + {"name": entry.name, "offset": entry.offset, "path": str(entry.path)} + for entry in secure_entries + ], + }, + ) + self._notify("error", "烧录策略异常", "force 策略校验失败,已停止以避免误写低地址。") + return False + self.log("[提示] 检测到安全启动已启用且启动链缺失:仅对 bootloader@0x0 启用 force。") + + strict_flash = bool(encrypt or self.secure_boot_enabled) + max_attempts = self.flash_secure_max_attempts if strict_flash else self.flash_max_attempts + attempts_used = 0 + flash_ok = False + last_output = "" + last_report: Optional[FlashReport] = None + last_failure_stage = "固件烧录" + baud_override: Optional[str] = None + + for attempt in range(1, max_attempts + 1): + attempts_used = attempt + if attempt > 1: + self.log(f"[提示] 第 {attempt} 次烧录尝试。") + code, output, report = self.do_flash( + port, + encrypt=encrypt, + entries=secure_entries, + on_output=self._on_flash_output, + baud_override=baud_override, + force=force_full_flash, + ) + last_output = output + last_report = report + if not output.strip(): + self.log("(无输出)") + if code == 0: + self.set_status("写后校验中...") + self.set_status_tone("info") + verify_ok, verify_err = self._verify_flash_readback( + port, report, encrypt, baud_override=baud_override + ) + self._log_flash_report(report) + if verify_ok: + self.log("[OK] 固件烧录完成。") + self.set_progress(100.0) + flash_ok = True + break + last_failure_stage = "写后校验" + last_output = verify_err or "readback_verify_failed" + self.log("[错误] 写后校验失败。") + else: + if detect_secure_boot_block(output): + self.secure_boot_blocked = True + self.update_flash_buttons() + if strict_flash: + break + + if attempt < max_attempts: + self.log("[警告] 烧录失败,准备复位后重试。") + if baud_override is None: + baud_override = "115200" + self.log("[提示] 已自动降速到 115200 后重试。") + self._attempt_reset(port) + time.sleep(1.0) + + if not flash_ok: + failure_entries = None + if last_report: + failure_entries = [ + { + "name": item.name, + "offset": item.offset, + "size": item.size, + "sha256": item.sha256, + "readback_sha256": item.readback_sha256, + "verified": item.verified, + } + for item in last_report.entries + ] + self._record_step_result( + "flash", + start_ts, + "failed", + { + "reason": last_failure_stage, + "attempts": attempts_used, + "mode": mode_label, + "encrypt": encrypt, + "app_only": app_only, + "force": force_full_flash, + "app_bin_name": self.private_cfg.app_bin_name, + "readback_errors": last_report.readback_errors if last_report else None, + "entries": failure_entries, + }, + ) + self.handle_flash_failure(last_failure_stage, last_output) + return False + + if last_report: + app_entry = next( + (item for item in last_report.entries if item.name == self.private_cfg.app_bin_name), + None, + ) + self._record_step_result( + "flash", + start_ts, + "ok", + { + "attempts": attempts_used, + "mode": mode_label, + "encrypt": encrypt, + "app_only": app_only, + "force": force_full_flash, + "app_bin_name": self.private_cfg.app_bin_name, + "entries": [ + { + "name": item.name, + "offset": item.offset, + "size": item.size, + "sha256": item.sha256, + "readback_sha256": item.readback_sha256, + "verified": item.verified, + } + for item in last_report.entries + ], + "readback_ok": last_report.readback_ok, + "readback_errors": last_report.readback_errors, + "app_sha256": app_entry.sha256 if app_entry else None, + }, + ) + else: + self._record_step_result( + "flash", + start_ts, + "ok", + { + "attempts": attempts_used, + "mode": mode_label, + "encrypt": encrypt, + "app_only": app_only, + "force": force_full_flash, + }, + ) + + self.log("[OK] 烧录完成。") + needs_reset = self._needs_post_flash_reset() + if not needs_reset: + self._post_flash_reset_done = True + if needs_reset: + self.log("[提示] 正在复位设备进入应用模式...") + self._attempt_reset(port) + self._post_flash_reset_done = True + time.sleep(1.0) + self.set_status("烧录完成。") + self.set_status_tone("success") + self.set_hint_text("", tone="success") + return True + + def post_flash_check_task(self, port: str) -> bool: + start_ts = time.time() + if not self.ensure_tools_available(): + self._record_step_result( + "post_flash_check", + start_ts, + "failed", + {"reason": "tools_missing"}, + ) + return False + + self.set_status("安全复检中...") + self.set_status_tone("info") + self.log_section("安全复检") + if not self.safe_log: + self.log(f"串口: {port}") + + timeout_s = max(5.0, self.ready_timeout_s) + deadline = time.time() + timeout_s + attempts = 0 + last_errors: list[str] = [] + last_details: dict = {} + last_output = "" + + if not self._post_flash_reset_done: + self._attempt_reset(port) + self._post_flash_reset_done = True + time.sleep(1.0) + + while time.time() < deadline: + attempts += 1 + wait_cap = 60.0 + if self.last_flash_app_only: + wait_cap = 5.0 + wait_s = min(wait_cap, max(1.0, deadline - time.time())) + self.log(f"[提示] 等待设备完成安全流程(约 {int(wait_s)} 秒)...") + time.sleep(wait_s) + code, output, summary_json = self._read_efuse_summary(port) + last_output = output + if code == 0 and summary_json is not None: + ok, details, errors = evaluate_post_flash_status( + summary_json, self.private_cfg.device_check + ) + last_details = details + if ok: + self.log("[OK] 安全复检通过。") + self._record_step_result( + "post_flash_check", + start_ts, + "ok", + { + "attempts": attempts, + "details": details, + }, + ) + self.set_status("安全复检完成。") + self.set_status_tone("success") + return True + last_errors = errors + else: + last_errors = [ + "summary_failed" if code != 0 else "summary_json_failed" + ] + + self.log("[错误] 安全复检失败。") + if not self.safe_log and last_output: + self.log(last_output) + if last_errors: + self.log(f"[错误] 安全复检未通过: {', '.join(last_errors)}") + self.set_status("安全复检失败。") + self.set_status_tone("error") + if last_errors: + self.set_hint_text( + "\n".join(f"- {item}" for item in last_errors), tone="error" + ) + self._notify("error", "安全复检失败", "安全复检失败,请查看日志。") + self._record_step_result( + "post_flash_check", + start_ts, + "failed", + { + "attempts": attempts, + "errors": last_errors, + "details": last_details, + }, + ) + return False + + def handle_flash_failure(self, stage: str, output: str) -> None: + self.log(f"[错误] {stage}失败。请检查连接、串口及 BOOT/RESET 操作。") + self.set_status(f"{stage}失败。") + self.set_status_tone("error") + self.set_progress(0.0) + if detect_secure_boot_block(output): + self.secure_boot_blocked = True + if self.secure_boot_enabled is not True: + self.secure_boot_enabled = True + self.card_secure_boot_var.set("已启用") + self.update_flash_buttons() + hints = collect_error_hints(output) + if detect_secure_boot_block(output): + if self.boot_chain_present is False: + hints.insert(0, "检测到安全启动限制:启动链缺失时请使用全量写入(必要时 force)。") + else: + hints.insert(0, "检测到安全启动已启用,常规更新仅允许模式 C(App-only)。") + self.set_hint_text("\n".join(f"- {hint}" for hint in hints), tone="error") + self.log("操作指引:", timestamp=False) + for hint in hints: + self.log(f" - {hint}", timestamp=False) + self._notify("error", f"{stage}失败", f"{stage}失败,请查看日志与提示。") + + def _attempt_reset(self, port: str) -> None: + args = ["--chip", self.private_cfg.chip, "--port", port, "run"] + code, output = self.run_tool("esptool", args, stream=False) + if code != 0: + self.log(f"[警告] 复位失败:{output.strip()}") + + def _needs_post_flash_reset(self) -> bool: + after = self.private_cfg.flash_args.get("after") + if after is None: + return False + after_norm = str(after).strip() + if not after_norm: + return False + after_norm = after_norm.replace("_", "-").lower() + return after_norm in ("no-reset", "noreset", "none", "no") + + def _read_flash_bytes( + self, port: str, offset: int, size: int, baud_override: Optional[str] = None + ) -> Optional[bytes]: + fd, temp_path = tempfile.mkstemp(suffix=".bin") + os.close(fd) + args = ["--chip", self.private_cfg.chip, "--port", port] + baud_value = baud_override or self.private_cfg.baud + if baud_value: + baud_int = _value_to_int(baud_value) + if baud_int is not None and baud_int > 921600 and baud_override is None: + baud_value = "115200" + args.extend(["--baud", str(baud_value)]) + try: + read_args = args + ["read-flash", hex(offset), str(size), temp_path] + code, output = self.run_tool("esptool", read_args, stream=False) + if code != 0 and ("Unknown command" in output or "invalid choice" in output): + read_args = args + ["read_flash", hex(offset), str(size), temp_path] + code, output = self.run_tool("esptool", read_args, stream=False) + if code != 0: + self.log(f"[警告] 读取闪存失败:{output.strip()}") + return None + try: + return Path(temp_path).read_bytes() + except OSError as exc: + self.log(f"[警告] 读取闪存临时文件失败:{exc}") + return None + finally: + try: + os.remove(temp_path) + except OSError: + pass + + def _partition_table_offset_from_layout(self) -> Optional[int]: + for offset, name in self.private_cfg.layout: + if not isinstance(name, str): + continue + lowered = name.lower() + if "partition-table" not in lowered and "partition_table" not in lowered: + continue + try: + return _parse_offset(offset) + except ValueError: + self.log(f"[警告] 分区表偏移无效:{offset}") + return None + return None + + def _probe_boot_chain_header(self, port: str) -> tuple[Optional[bool], Optional[str]]: + header = self._read_flash_bytes(port, 0x0, 32) + if header is None: + self.log("[警告] 启动链检测跳过:读取 flash@0x0 失败。") + return None, "probe_failed" + has_boot_chain, reason = detect_boot_chain_header_state(header) + if not has_boot_chain: + self.log(f"[警告] 启动链缺失(flash@0x0,{reason})。") + return False, reason + + partition_offset = self._partition_table_offset_from_layout() + if partition_offset is None: + self.log("[提示] 未找到分区表偏移,已按 bootloader 头有效处理。") + return True, "boot_ok_partition_unknown" + + partition_header = self._read_flash_bytes(port, partition_offset, 4) + if partition_header is None: + self.log(f"[警告] 启动链检测跳过:读取分区表头失败({hex(partition_offset)})。") + return None, "partition_probe_failed" + if all(value == 0xFF for value in partition_header): + self.log(f"[警告] 启动链缺失(分区表空白,{hex(partition_offset)})。") + return False, "partition_blank_ff" + if partition_header[:2] != b"\xAA\x50": + self.log( + f"[警告] 启动链缺失(分区表头无效,{hex(partition_offset)},magic=0x{partition_header[0]:02x}{partition_header[1]:02x})。" + ) + return False, "partition_invalid_magic" + + self.log("[OK] 启动链检测通过(bootloader/partition table 头有效)。") + return True, "ok" + + def _read_flash_segment( + self, port: str, offset: int, size: int, baud_override: Optional[str] = None + ) -> Optional[str]: + fd, temp_path = tempfile.mkstemp(suffix=".bin") + os.close(fd) + args = ["--chip", self.private_cfg.chip, "--port", port] + baud_value = baud_override or self.private_cfg.baud + if baud_value: + baud_int = _value_to_int(baud_value) + if baud_int is not None and baud_int > 921600 and baud_override is None: + baud_value = "115200" + args.extend(["--baud", str(baud_value)]) + try: + read_args = args + ["read-flash", hex(offset), str(size), temp_path] + code, output = self.run_tool("esptool", read_args, stream=False) + if code != 0 and ("Unknown command" in output or "invalid choice" in output): + read_args = args + ["read_flash", hex(offset), str(size), temp_path] + code, output = self.run_tool("esptool", read_args, stream=False) + if code != 0: + self.log(f"[错误] 读回校验失败:{output.strip()}") + return None + try: + hasher = hashlib.sha256() + total = 0 + with open(temp_path, "rb") as handle: + while True: + chunk = handle.read(65536) + if not chunk: + break + total += len(chunk) + hasher.update(chunk) + except OSError as exc: + self.log(f"[错误] 读回文件读取失败:{exc}") + return None + if total != size: + self.log(f"[警告] 读回长度异常:{total} != {size}") + return hasher.hexdigest() + finally: + try: + os.remove(temp_path) + except OSError: + pass + + def _verify_flash_readback( + self, + port: str, + report: FlashReport, + encrypt: bool, + baud_override: Optional[str] = None, + ) -> Tuple[bool, str]: + if encrypt: + report.readback_ok = True + report.readback_errors = [] + return True, "" + # esptool already performs in-band hash verification for plaintext writes. + # Extra readback can fail due transient port locking on some hosts; keep it opt-in. + if self.private_cfg.ui.get("strict_readback") is not True: + report.readback_ok = True + report.readback_errors = [] + return True, "" + if not report.entries: + return True, "" + ok = True + errors: list[str] = [] + for entry in report.entries: + readback_hash = self._read_flash_segment( + port, entry.offset, entry.size, baud_override=baud_override + ) + if readback_hash is None: + ok = False + errors.append(f"{entry.name}:readback_failed") + continue + entry.readback_sha256 = readback_hash + if encrypt: + entry.verified = None + continue + entry.verified = (entry.readback_sha256 == entry.sha256) + if entry.verified is False: + ok = False + errors.append(f"{entry.name}:hash_mismatch") + report.readback_ok = ok + report.readback_errors = errors + return ok, "; ".join(errors) + + def _log_flash_report(self, report: FlashReport) -> None: + if self.safe_log: + return + for entry in report.entries: + self.log(f"[HASH] {entry.name} sha256={entry.sha256}") + if entry.readback_sha256: + self.log(f"[READBACK] {entry.name} sha256={entry.readback_sha256}") + if entry.verified is True: + self.log(f"[OK] 校验一致: {entry.name}") + elif entry.verified is False: + self.log(f"[错误] 校验不一致: {entry.name}") + + def do_write_flash( + self, + port: str, + entries: Iterable[FlashSource], + encrypt: bool, + on_output: Optional[Callable[[str], None]], + baud_override: Optional[str] = None, + force: bool = False, + ) -> Tuple[int, str, FlashReport]: + if not self.ensure_esptool_api_available(): + return 1, "esptool API unavailable", FlashReport(entries=[], readback_ok=False, readback_errors=[]) + + entries_list = list(entries) + try: + decrypt_key = load_firmware_decrypt_key() + except FirmwareEnvelopeError as exc: + return 1, str(exc), FlashReport(entries=[], readback_ok=False, readback_errors=[]) + + report = FlashReport(entries=[], readback_ok=False, readback_errors=[]) + + def _run() -> None: + import esptool + from esptool import cmds + from esptool.logger import EsptoolLogger, log as esptool_log + + try: + EsptoolLogger._set_smart_features(False) + except Exception: + pass + try: + esptool_log.set_verbosity("normal") + except Exception: + pass + + baud_value = _value_to_int(baud_override or self.private_cfg.baud) + if baud_value is None: + raise ValueError(f"无效波特率: {baud_override or self.private_cfg.baud}") + bootstrap_baud = 115200 + use_stub = bool(self.private_cfg.flash_args.get("use_stub", True)) + + esp = esptool.detect_chip( + port=port, + baud=bootstrap_baud, + connect_mode=self.private_cfg.flash_args["before"], + ) + try: + if use_stub: + try: + esp = cmds.run_stub(esp) + except Exception as exc: # noqa: BLE001 + if on_output: + on_output(f"[警告] 启动 stub 失败,已降级为 no-stub: {exc}\n") + if baud_value != bootstrap_baud: + try: + esp.change_baud(baud_value) + except Exception as exc: # noqa: BLE001 + if on_output: + on_output( + f"[警告] 切换波特率到 {baud_value} 失败,继续使用 {bootstrap_baud}: {exc}\n" + ) + + # Keep API flow consistent with esptool CLI: attach SPI flash before write. + cmds.attach_flash(esp, None) + + for entry in entries_list: + try: + payload = entry.path.read_bytes() + except OSError as exc: + raise RuntimeError(f"固件读取失败: {entry.path.name}") from exc + try: + payload = decrypt_firmware_blob(payload, decrypt_key) + except FirmwareEnvelopeError as exc: + raise RuntimeError(f"固件解密失败: {entry.path.name}: {exc}") from exc + + plain: Optional[bytearray] = None + try: + plain = bytearray(payload) + payload = b"" + + sha256 = _sha256_hex(plain) + report.entries.append( + FlashEntryReport( + name=entry.name, + offset=entry.offset, + size=len(plain), + sha256=sha256, + readback_sha256=None, + verified=None, + ) + ) + write_kwargs = { + "flash_freq": self.private_cfg.flash_args["flash_freq"], + "flash_mode": self.private_cfg.flash_args["flash_mode"], + "flash_size": self.private_cfg.flash_args["flash_size"], + "encrypt": encrypt, + } + plain_bytes = bytes(plain) + if force and entry.offset < 0x8000 and not self._is_force_bootloader_entry(entry): + raise RuntimeError( + f"force 策略拒绝低地址条目: {entry.name} @ {hex(entry.offset)}" + ) + if force and self._is_force_bootloader_entry(entry): + self._guard_force_bootloader_write( + esp, + entry, + plain_bytes, + encrypt=encrypt, + ) + write_kwargs["force"] = True + try: + cmds.write_flash( + esp, + [(entry.offset, plain_bytes)], + **write_kwargs, + ) + except TypeError as exc: + if write_kwargs.get("force") and "force" in str(exc): + # Compatibility fallback for older esptool APIs. + write_kwargs.pop("force", None) + if on_output: + on_output("[警告] 当前 esptool API 不支持 force 参数,已回退默认写入。\n") + cmds.write_flash( + esp, + [(entry.offset, plain_bytes)], + **write_kwargs, + ) + else: + raise + finally: + _wipe_bytearray(plain) + after = self.private_cfg.flash_args["after"] + if after: + after_norm = str(after).strip() + if after_norm: + after_norm = after_norm.replace("_", "-").lower() + if after_norm not in ("no-reset", "noreset", "none", "no"): + esptool.reset_chip(esp, reset_mode=after_norm) + finally: + try: + esp._port.close() + except Exception: + pass + + code, output = run_esptool_api(_run, stream=True, on_output=on_output) + return code, output, report + + def ensure_esptool_api_available(self) -> bool: + try: + import esptool # noqa: F401 + + return True + except Exception as exc: # noqa: BLE001 + if self.frozen: + self._notify( + "error", + "缺少 esptool", + "内置环境缺少 esptool,请重新打包。\n\n" + str(exc), + ) + return False + script_path = self.base_dir / "flash_encrypted_gui.py" + self._notify( + "error", + "缺少 esptool", + "当前 Python 环境缺少 esptool(API 模式需要同一解释器可用)。\n" + f"请使用虚拟环境运行:\n {self.python_cmd} {script_path}\n" + f"或安装:\n {sys.executable} -m pip install esptool\n\n{exc}", + ) + return False + + def ensure_tools_available(self) -> bool: + if not self.ensure_esptool_api_available(): + return False + code, output = self.run_tool("esptool", ["version"], stream=False) + if code != 0: + if self.frozen: + self._notify("error", "缺少 esptool", "内置环境缺少 esptool,请重新打包。\n\n" + output) + return False + self._notify( + "error", + "缺少 esptool", + "未检测到 esptool。请先安装:\n" + f"{self.python_cmd} -m pip install esptool\n\n{output}", + ) + return False + + if self.frozen: + try: + import importlib + + importlib.import_module("espefuse") + except Exception as exc: # noqa: BLE001 + self._notify( + "error", + "缺少 espefuse", + "内置环境缺少 espefuse,请重新打包。\n\n" + str(exc), + ) + return False + return True + + code, output = self.run_tool("espefuse", ["--help"], stream=False) + if code != 0: + self._notify( + "error", + "缺少 espefuse", + "未检测到 espefuse。请先安装:\n" + f"{self.python_cmd} -m pip install esptool\n\n{output}", + ) + return False + return True + + def run_tool( + self, + tool: str, + args: Iterable[str], + stream: bool, + on_output: Optional[Callable[[str], None]] = None, + ) -> Tuple[int, str]: + if self.frozen: + return run_tool_module(tool, args, cwd=self.base_dir, stream=stream, on_output=on_output) + if self.python_cmd is None: + return 1, "Python 环境未就绪。" + cmd = [str(self.python_cmd), "-m", tool, *args] + return run_command(cmd, cwd=self.base_dir, stream=stream, on_output=on_output) + + def do_flash( + self, + port: str, + encrypt: bool, + entries: Iterable[FlashSource], + on_output: Optional[Callable[[str], None]], + baud_override: Optional[str] = None, + force: bool = False, + ) -> Tuple[int, str, FlashReport]: + return self.do_write_flash( + port, + entries, + encrypt=encrypt, + on_output=on_output, + baud_override=baud_override, + force=force, + ) + + def run(self) -> None: + self.root.mainloop() + + +def main() -> None: + try: + app = FlashGUI() + except (FileNotFoundError, PermissionError, ValueError, KeyError, TypeError) as exc: + try: + messagebox.showerror("配置错误", str(exc)) + except tk.TclError: + print(exc, file=sys.stderr) + raise SystemExit(1) from exc + app.run() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(1) diff --git a/tools/esptool-factory/factory_gui/config.py b/tools/esptool-factory/factory_gui/config.py new file mode 100644 index 0000000..66a9941 --- /dev/null +++ b/tools/esptool-factory/factory_gui/config.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from factory_private_config import get_optional, get_required, load_private_config + +from .models import DeviceCheckConfig, FlashPrivateConfig + +_ALLOWED_FLASH_MODE = {"qio", "qout", "dio", "dout", "keep"} +_ALLOWED_FLASH_FREQ = { + "keep", + "80m", + "60m", + "48m", + "40m", + "30m", + "26m", + "24m", + "20m", + "16m", + "15m", + "12m", +} +_ALLOWED_FLASH_SIZE = { + "keep", + "detect", + "256kb", + "512kb", + "1mb", + "2mb", + "2mb-c1", + "4mb", + "4mb-c1", + "8mb", + "16mb", + "32mb", + "64mb", + "128mb", +} +_ALLOWED_FLASH_BEFORE = {"default-reset", "usb-reset", "no-reset", "no-reset-no-sync"} +_ALLOWED_FLASH_AFTER = { + "default-reset", + "hard-reset", + "soft-reset", + "watchdog-reset", + "no-reset", + "no-reset-stub", + "noreset", + "none", + "no", +} + + +def _coerce_str(value: object, label: str) -> str: + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str) and value.strip(): + return value.strip() + raise ValueError(f"私有配置无效: {label}") + + +def _optional_str(value: object) -> Optional[str]: + if isinstance(value, str): + text = value.strip() + return text if text else None + return None + + +def _optional_bool_or_none(value: object) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(int(value)) + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off"}: + return False + return None + + +def _coerce_positive_float(value: Optional[float], fallback: float) -> float: + if value is None: + return fallback + try: + parsed = float(value) + except (TypeError, ValueError): + return fallback + return max(1.0, parsed) + + +def _coerce_positive_int(value: Optional[int], fallback: int) -> int: + if value is None: + return fallback + try: + parsed = int(value) + except (TypeError, ValueError): + return fallback + return max(1, parsed) + + +def _ui_text(ui_cfg: dict, key: str, fallback: str) -> str: + value = ui_cfg.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return fallback + + +def _coerce_offset_int(value: str, label: str) -> int: + try: + return int(value, 0) + except ValueError as exc: + raise ValueError(f"私有配置无效: {label}") from exc + + +def _validate_allowed( + value: str, + label: str, + allowed: set[str], + *, + normalize_dash: bool = False, +) -> str: + normalized = value.strip().lower() + if normalize_dash: + normalized = normalized.replace("_", "-") + if normalized not in allowed: + allowed_text = ", ".join(sorted(allowed)) + raise ValueError(f"私有配置无效: {label}={value}(允许: {allowed_text})") + return normalized + + +def _validate_app_layout_binding( + layout: list[tuple[str, str]], + app_bin_name: str, + app_partition_offset: str, +) -> None: + app_offsets = [offset for offset, name in layout if name == app_bin_name] + if not app_offsets: + raise ValueError("私有配置无效: flash.app_bin_name 必须出现在 flash.layout") + + app_layout_offsets = {_coerce_offset_int(offset, "flash.layout.offset") for offset in app_offsets} + if len(app_layout_offsets) != 1: + raise ValueError("私有配置无效: flash.layout 中 app_bin_name 对应多个偏移地址") + + app_offset = _coerce_offset_int(app_partition_offset, "flash.app_partition_offset") + if app_offset not in app_layout_offsets: + layout_offset = app_offsets[0] + raise ValueError( + "私有配置无效: flash.app_partition_offset 与 flash.layout 中 app_bin_name 的 offset 不一致" + f"(app_partition_offset={app_partition_offset}, layout_offset={layout_offset})" + ) + + +def _load_flash_private_config( + base_dir: Path, raw: Optional[dict] = None +) -> FlashPrivateConfig: + if raw is None: + raw = load_private_config(base_dir) + flash_cfg = get_required(raw, ("flash",), (dict,)) + chip = _coerce_str(get_required(flash_cfg, ("chip",), (str, int)), "flash.chip") + baud = _coerce_str(get_required(flash_cfg, ("baud",), (str, int)), "flash.baud") + bin_dir_raw = _coerce_str(get_required(flash_cfg, ("bin_dir",), (str,)), "flash.bin_dir") + bin_dir = Path(bin_dir_raw) + + bin_enc_raw = get_optional(flash_cfg, ("bin_encryption",), None) + if bin_enc_raw is not None: + raise ValueError("私有配置无效: flash.bin_encryption 已移除。") + + layout_raw = get_required(flash_cfg, ("layout",), (list,)) + layout: list[tuple[str, str]] = [] + for entry in layout_raw: + offset: Optional[str] = None + name: Optional[str] = None + if isinstance(entry, (list, tuple)) and len(entry) == 2: + offset = _coerce_str(entry[0], "flash.layout.offset") + name = _coerce_str(entry[1], "flash.layout.name") + elif isinstance(entry, dict): + offset = _coerce_str(entry.get("offset"), "flash.layout.offset") + name = _coerce_str(entry.get("name"), "flash.layout.name") + else: + raise ValueError("私有配置无效: flash.layout") + if name.endswith(".enc"): + raise ValueError("私有配置无效: flash.layout 不应包含 .enc 后缀。") + layout.append((offset, name)) + if not layout: + raise ValueError("私有配置无效: flash.layout 不能为空") + + app_bin_name = _coerce_str( + get_required(flash_cfg, ("app_bin_name",), (str,)), "flash.app_bin_name" + ) + if app_bin_name.endswith(".enc"): + raise ValueError("私有配置无效: flash.app_bin_name 不应包含 .enc 后缀。") + app_partition_offset = _coerce_str( + get_required(flash_cfg, ("app_partition_offset",), (str, int)), + "flash.app_partition_offset", + ) + _validate_app_layout_binding(layout, app_bin_name, app_partition_offset) + + flash_args_raw = get_required(flash_cfg, ("flash_args",), (dict,)) + use_stub_value = _optional_bool_or_none(get_optional(flash_args_raw, ("use_stub",), None)) + flash_args = { + "flash_mode": _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("flash_mode",), (str, int)), + "flash.flash_args.flash_mode", + ), + "flash.flash_args.flash_mode", + _ALLOWED_FLASH_MODE, + ), + "flash_freq": _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("flash_freq",), (str, int)), + "flash.flash_args.flash_freq", + ), + "flash.flash_args.flash_freq", + _ALLOWED_FLASH_FREQ, + ), + "flash_size": _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("flash_size",), (str, int)), + "flash.flash_args.flash_size", + ), + "flash.flash_args.flash_size", + _ALLOWED_FLASH_SIZE, + ), + "before": _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("before",), (str, int)), + "flash.flash_args.before", + ), + "flash.flash_args.before", + _ALLOWED_FLASH_BEFORE, + normalize_dash=True, + ), + "after": _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("after",), (str, int)), + "flash.flash_args.after", + ), + "flash.flash_args.after", + _ALLOWED_FLASH_AFTER, + normalize_dash=True, + ), + "use_stub": True if use_stub_value is None else use_stub_value, + } + + check_cfg = get_required(raw, ("device_check",), (dict,)) + crypt_cnt_fields_raw = get_required(check_cfg, ("crypt_cnt_fields",), (list,)) + crypt_cnt_fields = tuple(_coerce_str(item, "device_check.crypt_cnt_fields") for item in crypt_cnt_fields_raw) + crypt_cnt_expect = _optional_str(get_optional(check_cfg, ("crypt_cnt_expect",), None)) + if crypt_cnt_expect: + expect_norm = crypt_cnt_expect.strip().lower() + if expect_norm not in {"odd", "even", "zero", "any"}: + raise ValueError("私有配置无效: device_check.crypt_cnt_expect") + crypt_cnt_expect = expect_norm + secure_boot_expected = _optional_bool_or_none( + get_optional(check_cfg, ("secure_boot_expected",), None) + ) + secure_boot_field = _coerce_str( + get_required(check_cfg, ("secure_boot_field",), (str,)), "device_check.secure_boot_field" + ) + flash_key_purpose_match = _optional_str( + get_optional(check_cfg, ("flash_key_purpose_match",), None) + ) + secure_boot_key_purpose_match = _optional_str( + get_optional(check_cfg, ("secure_boot_key_purpose_match",), None) + ) + key_read_disable_field = _coerce_str( + get_required(check_cfg, ("key_read_disable_field",), (str,)), + "device_check.key_read_disable_field", + ) + ui_cfg = get_optional(raw, ("ui",), {}) + if not isinstance(ui_cfg, dict): + ui_cfg = {} + return FlashPrivateConfig( + chip=chip, + baud=baud, + bin_dir=bin_dir, + layout=layout, + app_bin_name=app_bin_name, + app_partition_offset=app_partition_offset, + flash_args=flash_args, + device_check=DeviceCheckConfig( + crypt_cnt_fields=crypt_cnt_fields, + secure_boot_field=secure_boot_field, + secure_boot_expected=secure_boot_expected, + crypt_cnt_expect=crypt_cnt_expect, + flash_key_purpose_match=flash_key_purpose_match, + secure_boot_key_purpose_match=secure_boot_key_purpose_match, + key_read_disable_field=key_read_disable_field, + ), + ui=ui_cfg, + ) diff --git a/tools/esptool-factory/factory_gui/constants.py b/tools/esptool-factory/factory_gui/constants.py new file mode 100644 index 0000000..458a2fc --- /dev/null +++ b/tools/esptool-factory/factory_gui/constants.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import os + +DEFAULT_BAUD = int(os.getenv("FACTORY_UART_BAUD", "115200")) +DEFAULT_READY_TIMEOUT_S = float(os.getenv("FACTORY_READY_TIMEOUT_S", "180")) +DEFAULT_FLASH_MAX_ATTEMPTS = int(os.getenv("FACTORY_FLASH_MAX_ATTEMPTS", "2")) +DEFAULT_FLASH_SECURE_MAX_ATTEMPTS = int(os.getenv("FACTORY_FLASH_SECURE_MAX_ATTEMPTS", "3")) diff --git a/tools/esptool-factory/factory_gui/flash_utils.py b/tools/esptool-factory/factory_gui/flash_utils.py new file mode 100644 index 0000000..9077f19 --- /dev/null +++ b/tools/esptool-factory/factory_gui/flash_utils.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path +from typing import Iterable, Optional, Tuple + +from .models import DeviceCheckConfig + +_MAC_RE = re.compile(r"(?i)([0-9a-f]{2}:){5}[0-9a-f]{2}") +_MAC_OUTPUT_RE = re.compile(r"(?i)\bMAC:\s*([0-9a-f]{2}(?::[0-9a-f]{2}){5})") +_ESP_IMAGE_MAGIC = 0xE9 + + +def _sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _wipe_bytearray(buf: Optional[bytearray]) -> None: + if not buf: + return + for idx in range(len(buf)): + buf[idx] = 0 + + +def collect_error_hints(output: str) -> list[str]: + lowered = output.lower() + hints: list[str] = [] + + if "secure boot detected" in lowered or "writing to flash regions < 0x8000 is disabled" in lowered: + hints.append("检测到写入限制:请按工艺选择模式。") + hints.append("请按工艺选择模式后重试。") + if "secure download mode is enabled" in lowered: + hints.append("检测到下载限制:请按工艺选择模式。") + if "could not open port" in lowered or "permissionerror" in lowered or "access is denied" in lowered: + hints.append("串口被占用或权限不足,关闭串口工具后重试。") + if "no serial data received" in lowered or "failed to connect to esp32" in lowered: + hints.append("确认串口号正确,未被其他软件占用。") + hints.append("按住 BOOT,点一下 RESET 进入下载模式。") + if "download mode successfully detected" in lowered and "no sync reply" in lowered: + hints.append("下载模式已进入但无法同步,通常是 TX/RX 线序或供电问题。") + if "serial tx path seems to be down" in lowered: + hints.append("检查 USB 转串口的 TX/RX 线序、焊接和接触。") + if "invalid head of packet" in lowered or "possible serial noise" in lowered: + hints.append("串口噪声/干扰,尝试更换 USB 线/接口或降低波特率(如 115200)。") + if "stopiteration" in lowered: + hints.append("串口通信异常中断,重新插拔设备后重试。") + + if not hints: + hints.append("请检查串口号、线缆/供电,并按住 BOOT 点一下 RESET 进入下载模式。") + + deduped: list[str] = [] + seen = set() + for hint in hints: + if hint in seen: + continue + seen.add(hint) + deduped.append(hint) + return deduped + + +def detect_secure_boot_block(output: str) -> bool: + lowered = output.lower() + return ( + "secure boot detected" in lowered + or "writing to flash regions < 0x8000 is disabled" in lowered + ) + + +def detect_boot_chain_header_state(header_bytes: bytes) -> tuple[bool, str]: + if not header_bytes: + return False, "empty" + if all(value == 0xFF for value in header_bytes): + return False, "blank_ff" + if all(value == 0x00 for value in header_bytes): + return False, "blank_00" + first = header_bytes[0] + if first != _ESP_IMAGE_MAGIC: + return False, f"invalid_magic_0x{first:02x}" + return True, "ok" + + +def _extract_json_blob(output: str) -> Optional[dict]: + start = output.find("{") + end = output.rfind("}") + if start < 0 or end <= start: + return None + try: + return json.loads(output[start : end + 1]) + except json.JSONDecodeError: + return None + + +def _json_field_value(data: Optional[dict], name: str) -> Optional[object]: + if not isinstance(data, dict): + return None + field = data.get(name) + if not isinstance(field, dict): + return None + return field.get("value") + + +def _value_to_int(value: object) -> Optional[int]: + if isinstance(value, bool): + return int(value) + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + stripped = value.strip() + lowered = stripped.lower() + if lowered in ("true", "yes", "enable", "enabled", "on"): + return 1 + if lowered in ("false", "no", "disable", "disabled", "off"): + return 0 + if lowered.startswith("0x"): + try: + return int(stripped, 16) + except ValueError: + return None + if lowered.startswith("0b"): + try: + return int(stripped, 2) + except ValueError: + return None + if stripped.isdigit(): + return int(stripped) + return None + + +def get_efuse_flag(data: Optional[dict], *field_names: str) -> Optional[bool]: + if not isinstance(data, dict): + return None + for name in field_names: + if not isinstance(name, str) or not name: + continue + value = _json_field_value(data, name) + value_int = _value_to_int(value) + if value_int is None: + continue + return bool(value_int) + return None + + +def _parse_offset(value: object) -> int: + parsed = _value_to_int(value) + if parsed is None: + raise ValueError(f"无效偏移: {value}") + return parsed + + +def recommend_mode_from_json( + data: dict, crypt_cnt_fields: Iterable[str] +) -> Tuple[Optional[str], Optional[str]]: + targets = tuple(crypt_cnt_fields) + if not targets: + return None, "CRYPT_CNT_NOT_CONFIGURED" + for name in targets: + value = _json_field_value(data, name) + if value is None: + continue + value_int = _value_to_int(value) + if value_int is not None: + ones = bin(value_int).count("1") + if ones == 0: + return "1", f"{name}_ZERO" + if ones % 2 == 1: + return "2", f"{name}_ODD" + return None, f"{name}_UNEXPECTED" + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("enable", "enabled"): + return "2", f"{name}_ENABLE" + if lowered in ("disable", "disabled"): + return "1", f"{name}_DISABLE" + return None, "CRYPT_CNT_NOT_FOUND" + + +def _crypt_cnt_parity(data: dict, crypt_cnt_fields: Iterable[str]) -> Optional[int]: + for name in crypt_cnt_fields: + value = _json_field_value(data, name) + value_int = _value_to_int(value) + if value_int is None: + continue + ones = bin(value_int).count("1") + return ones % 2 + return None + + +def _purpose_matches(value: object, match_text: Optional[str]) -> Optional[bool]: + if match_text is None: + return None + if isinstance(value, str): + return match_text.upper() in value.upper() + if isinstance(value, (int, float)): + if int(value) == 0: + return False + return None + + +def _match_secure_boot_purpose(value: object) -> bool: + if not isinstance(value, str): + return False + return value.upper().startswith("SECURE_BOOT_DIGEST") + + +def _key_index_from_purpose_name(name: Optional[str]) -> Optional[int]: + if not name: + return None + if not name.startswith("KEY_PURPOSE_"): + return None + suffix = name[len("KEY_PURPOSE_") :] + if not suffix.isdigit(): + return None + return int(suffix) + + +def _rd_dis_for_key(rd_dis_value: Optional[int], key_name: Optional[str]) -> Optional[bool]: + if rd_dis_value is None: + return None + key_index = _key_index_from_purpose_name(key_name) + if key_index is None: + return None + return bool(rd_dis_value & (1 << key_index)) + + +def _iter_key_purpose_fields(data: dict) -> list[tuple[str, object]]: + rows: list[tuple[str, object]] = [] + if not isinstance(data, dict): + return rows + for key_name, raw_field in data.items(): + if not isinstance(key_name, str): + continue + if not key_name.startswith("KEY_PURPOSE_"): + continue + value = None + if isinstance(raw_field, dict): + value = raw_field.get("value") + rows.append((key_name, value)) + + def _sort_key(row: tuple[str, object]) -> tuple[int, str]: + key_name = row[0] + idx = _key_index_from_purpose_name(key_name) + if idx is None: + return (1_000_000, key_name) + return (idx, key_name) + + rows.sort(key=_sort_key) + return rows + + +def _purpose_value_matches( + value: object, + expected_match: Optional[str], + allow_secure_boot_prefix: bool = False, +) -> bool: + matched = _purpose_matches(value, expected_match) + if matched is True: + return True + if not allow_secure_boot_prefix or not expected_match: + return False + if "SECURE_BOOT_DIGEST" not in expected_match.upper(): + return False + return _match_secure_boot_purpose(value) + + +def _collect_expected_purpose_matches(cfg: DeviceCheckConfig) -> tuple[list[str], list[str]]: + flash_expected: list[str] = [] + secure_expected: list[str] = [] + + flash_match = cfg.flash_key_purpose_match + if flash_match: + flash_match = flash_match.strip() + if flash_match: + flash_expected.append(flash_match) + + secure_boot_match = cfg.secure_boot_key_purpose_match + if secure_boot_match: + secure_boot_match = secure_boot_match.strip() + if secure_boot_match: + secure_expected.append(secure_boot_match) + + return flash_expected, secure_expected + + +def _match_expected_keys( + key_values: list[tuple[str, object]], + expected_matches: list[str], + allow_secure_boot_prefix: bool = False, +) -> tuple[list[dict], list[str]]: + matches: list[dict] = [] + missing: list[str] = [] + used_keys: set[str] = set() + for expected in expected_matches: + found: Optional[dict] = None + for key_name, value in key_values: + if key_name in used_keys: + continue + if _purpose_value_matches( + value, + expected, + allow_secure_boot_prefix=allow_secure_boot_prefix, + ): + found = {"key": key_name, "value": value} + break + if found is None: + missing.append(expected) + continue + used_keys.add(found["key"]) + matches.append(found) + return matches, missing + + +def evaluate_post_flash_status( + data: dict, cfg: DeviceCheckConfig +) -> Tuple[bool, dict, list[str]]: + details: dict = {} + errors: list[str] = [] + + crypt_cnt_mode, crypt_cnt_reason = recommend_mode_from_json(data, cfg.crypt_cnt_fields) + details["crypt_cnt_mode"] = crypt_cnt_mode + details["crypt_cnt_reason"] = crypt_cnt_reason + parity = _crypt_cnt_parity(data, cfg.crypt_cnt_fields) + if parity is not None: + details["crypt_cnt_parity"] = "odd" if parity == 1 else "even" + if cfg.crypt_cnt_expect and cfg.crypt_cnt_expect != "any": + expect = cfg.crypt_cnt_expect + if expect == "odd" and crypt_cnt_mode != "2": + errors.append("crypt_cnt_not_odd") + elif expect == "zero" and crypt_cnt_mode != "1": + errors.append("crypt_cnt_not_zero") + elif expect == "even" and crypt_cnt_mode == "2": + errors.append("crypt_cnt_unexpected_odd") + + secure_boot_value = _value_to_int(_json_field_value(data, cfg.secure_boot_field)) + secure_boot_enabled: Optional[bool] = None + if secure_boot_value is not None: + secure_boot_enabled = bool(secure_boot_value) + details["secure_boot_enabled"] = secure_boot_enabled + if cfg.secure_boot_expected is not None and secure_boot_enabled is not None: + if secure_boot_enabled != cfg.secure_boot_expected: + errors.append("secure_boot_unexpected") + elif cfg.secure_boot_expected is not None and secure_boot_enabled is None: + errors.append("secure_boot_unknown") + + rd_dis_value = _value_to_int(_json_field_value(data, cfg.key_read_disable_field)) + details["rd_dis_value"] = rd_dis_value + + key_details: dict = {} + + key_values = _iter_key_purpose_fields(data) + flash_expected, secure_expected = _collect_expected_purpose_matches(cfg) + flash_matches, flash_missing = _match_expected_keys( + key_values, flash_expected, allow_secure_boot_prefix=False + ) + secure_matches, secure_missing = _match_expected_keys( + key_values, secure_expected, allow_secure_boot_prefix=True + ) + + flash_rd_details: list[dict] = [] + secure_rd_details: list[dict] = [] + flash_rd_missing = False + flash_rd_unknown = False + secure_rd_blocked = False + secure_rd_unknown = False + + for matched in flash_matches: + key_name = matched["key"] + rd_ok = _rd_dis_for_key(rd_dis_value, key_name) + flash_rd_details.append({"key": key_name, "rd_protected": rd_ok}) + if rd_ok is False: + flash_rd_missing = True + elif rd_ok is None: + flash_rd_unknown = True + + for matched in secure_matches: + key_name = matched["key"] + rd_ok = _rd_dis_for_key(rd_dis_value, key_name) + secure_rd_details.append({"key": key_name, "rd_protected": rd_ok}) + if rd_ok is True: + secure_rd_blocked = True + elif rd_ok is None: + secure_rd_unknown = True + + if flash_expected and flash_missing: + errors.append("flash_key_missing") + if secure_expected and secure_missing: + errors.append("secure_boot_key_missing") + if flash_matches and flash_rd_missing: + errors.append("flash_key_rd_dis_missing") + elif flash_matches and flash_rd_unknown: + errors.append("flash_key_rd_dis_unknown") + if secure_matches and secure_rd_blocked: + errors.append("secure_boot_key_rd_dis_set") + elif secure_matches and secure_rd_unknown: + errors.append("secure_boot_key_rd_dis_unknown") + + key_details["flash_enc_keys"] = flash_matches + key_details["secure_boot_keys"] = secure_matches + key_details["flash_enc_rd_protected"] = flash_rd_details + key_details["secure_boot_rd_protected"] = secure_rd_details + details["keys"] = key_details + + return len(errors) == 0, details, errors + + +def build_flash_entries( + bin_dir: Path, + layout: Iterable[Tuple[str, str]], + app_only: bool, + app_partition_offset: str, + app_bin_name: str, +) -> list[Tuple[object, Path]]: + if app_only: + return [(app_partition_offset, bin_dir / app_bin_name)] + return [(offset, bin_dir / name) for offset, name in layout] + + +def recommend_mode( + summary_text: str, crypt_cnt_fields: Iterable[str] +) -> Tuple[Optional[str], Optional[str]]: + targets = tuple(crypt_cnt_fields) + if not targets: + return None, "CRYPT_CNT_NOT_CONFIGURED" + for line in summary_text.splitlines(): + if "CRYPT_CNT_DISABLE" in line: + continue + if not any(target in line for target in targets): + continue + match = re.search(r"\(0b([01]+)\)", line) + if match: + ones = match.group(1).count("1") + else: + match = re.search(r"=\s*([0-9]+)", line) + if not match: + return None, "CRYPT_CNT_PARSE_FAIL" + value = int(match.group(1), 10) + ones = bin(value).count("1") + if ones == 0: + return "1", "CRYPT_CNT_ZERO" + if ones % 2 == 1: + return "2", "CRYPT_CNT_ODD" + return None, "CRYPT_CNT_UNEXPECTED" + return None, "CRYPT_CNT_NOT_FOUND" + + +def extract_mac_from_output(output: str) -> Optional[str]: + match = _MAC_OUTPUT_RE.search(output) or _MAC_RE.search(output) + if not match: + return None + mac = match.group(1) if match.re is _MAC_OUTPUT_RE else match.group(0) + return mac.lower() diff --git a/tools/esptool-factory/factory_gui/models.py b/tools/esptool-factory/factory_gui/models.py new file mode 100644 index 0000000..b997689 --- /dev/null +++ b/tools/esptool-factory/factory_gui/models.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + + +@dataclass(frozen=True) +class DeviceCheckConfig: + crypt_cnt_fields: Tuple[str, ...] + secure_boot_field: str + secure_boot_expected: Optional[bool] + crypt_cnt_expect: Optional[str] + flash_key_purpose_match: Optional[str] + secure_boot_key_purpose_match: Optional[str] + key_read_disable_field: str + + +@dataclass(frozen=True) +class FlashPrivateConfig: + chip: str + baud: str + bin_dir: Path + layout: list[Tuple[str, str]] + app_bin_name: str + app_partition_offset: str + flash_args: dict + device_check: DeviceCheckConfig + ui: dict + + +@dataclass(frozen=True) +class FlashSource: + offset: int + path: Path + name: str + + +@dataclass +class FlashEntryReport: + name: str + offset: int + size: int + sha256: str + readback_sha256: Optional[str] + verified: Optional[bool] + + +@dataclass +class FlashReport: + entries: list[FlashEntryReport] + readback_ok: bool + readback_errors: list[str] diff --git a/tools/esptool-factory/factory_gui/path_utils.py b/tools/esptool-factory/factory_gui/path_utils.py new file mode 100644 index 0000000..d2931b8 --- /dev/null +++ b/tools/esptool-factory/factory_gui/path_utils.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Optional + + +def is_frozen() -> bool: + return getattr(sys, "frozen", False) + + +def _find_app_bundle_root(exe_path: Path) -> Optional[Path]: + for parent in exe_path.parents: + if parent.suffix.lower() == ".app": + return parent + return None + + +def _pick_runtime_root(exe_path: Path) -> Path: + candidates: list[Path] = [] + app_root = _find_app_bundle_root(exe_path) + if app_root is not None: + candidates.append(app_root.parent) + candidates.append(exe_path.parent) + for candidate in candidates: + if (candidate / "conf" / "factory_private.json.enc").exists(): + return candidate + if (candidate / "conf").exists() or (candidate / "bin").exists() or (candidate / "keys").exists(): + return candidate + return exe_path.parent + + +def venv_python(base_dir: Path) -> Path: + scripts = "Scripts" if os.name == "nt" else "bin" + exe = "python.exe" if os.name == "nt" else "python3" + return base_dir / ".venv" / scripts / exe + + +def resolve_python(base_dir: Path) -> Path: + venv_py = venv_python(base_dir) + if venv_py.exists(): + return venv_py + raise FileNotFoundError( + "未找到当前目录的虚拟环境:\n" + f"{venv_py}\n" + "请先创建并安装依赖:\n" + f" python3 -m venv {base_dir / '.venv'}\n" + f" {venv_py} -m pip install -r {base_dir / 'requirements.txt'}" + ) + + +def get_base_dir() -> Path: + if is_frozen(): + return _pick_runtime_root(Path(sys.executable).resolve()) + return Path(__file__).resolve().parent.parent + + +def resolve_bin_dir(base_dir: Path, configured: Path) -> Path: + if configured.is_absolute(): + return configured + return base_dir / configured + + +def select_bin_dir(base_dir: Path, configured: Path) -> Path: + return resolve_bin_dir(base_dir, configured) + + +def _display_path(base_dir: Path, path: Path) -> str: + try: + rel = path.relative_to(base_dir) + return str(rel) + except ValueError: + return path.name diff --git a/tools/esptool-factory/factory_gui/redact.py b/tools/esptool-factory/factory_gui/redact.py new file mode 100644 index 0000000..a4ee1ca --- /dev/null +++ b/tools/esptool-factory/factory_gui/redact.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import os +import re + +SENSITIVE_KEYS = ( + "pub_key", + "signature", + "key_b64", + "flash_key_b64", + "sig_b64", + "pub_key_jkt", + "new_pub_key", + "new_pub_key_jkt", + "device_mac", + "operator", + "host", + "port", + "firmware_version", +) + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +_MAC_RE = re.compile(r"(?i)([0-9a-f]{2}:){5}[0-9a-f]{2}") +_UUID_RE = re.compile( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" +) +_URL_RE = re.compile(r"https?://[^\s]+") +_DEVICE_ID_JSON_RE = re.compile(r'("device_id"\s*:\s*")([^"]+)(")', re.IGNORECASE) +_DEVICE_ID_KV_RE = re.compile(r"(\bdevice_id\b\s*[:=]\s*)([^\s,;]+)", re.IGNORECASE) +_JSON_KEY_RE = re.compile( + r'([\'"](?:(?:' + "|".join(SENSITIVE_KEYS) + r'))[\'"]\s*:\s*[\'"])([^\'"]+)([\'"])', + re.IGNORECASE, +) +_KV_RE = re.compile(r"(?i)\b(" + "|".join(SENSITIVE_KEYS) + r")=([^\s,;]+)") +_HEX_BYTES_RE = re.compile(r"\b(?:[0-9a-fA-F]{2}\s+){7,}[0-9a-fA-F]{2}\b") +_BIN_PATH_RE = re.compile(r"(/[^\\s]+?\\.(?:bin(?:\\.enc)?|enc))") +_BIN_NAME_RE = re.compile(r"\b[\w.-]+\\.(?:bin(?:\\.enc)?|enc)\b") + + +def _sanitize_filename(value: str) -> str: + if not value: + return "unknown" + return re.sub(r"[^A-Za-z0-9._-]+", "_", value) + + +def _preserve_device_ids(text: str) -> tuple[str, dict[str, str]]: + tokens: dict[str, str] = {} + + def _tokenize(value: str) -> str: + token = f"__DEVICE_ID_{len(tokens)}__" + tokens[token] = value + return token + + def _replace_json(match: re.Match[str]) -> str: + return f"{match.group(1)}{_tokenize(match.group(2))}{match.group(3)}" + + def _replace_kv(match: re.Match[str]) -> str: + return f"{match.group(1)}{_tokenize(match.group(2))}" + + text = _DEVICE_ID_JSON_RE.sub(_replace_json, text) + text = _DEVICE_ID_KV_RE.sub(_replace_kv, text) + return text, tokens + + +def _redact_sensitive(text: str) -> str: + if not text: + return text + text, device_tokens = _preserve_device_ids(text) + text = _ANSI_RE.sub("", text) + text = _MAC_RE.sub("", text) + text = _UUID_RE.sub("", text) + text = _URL_RE.sub("", text) + text = _JSON_KEY_RE.sub( + lambda m: f"{m.group(1)}{m.group(3)}", text + ) + text = _KV_RE.sub( + lambda m: f"{m.group(1)}=", text + ) + text = _HEX_BYTES_RE.sub("", text) + text = _BIN_PATH_RE.sub(lambda m: os.path.basename(m.group(1)), text) + text = _BIN_NAME_RE.sub("", text) + lowered = text.lower() + if "security features enabled" in lowered: + return "检测到限制,保持默认配置。" + if "compress and encrypt options are mutually exclusive" in lowered: + return "写入选项冲突,已使用默认方式。" + if "will flash input bytes uncompressed" in lowered: + return "将按默认方式写入。" + if "cannot verify written data" in lowered: + return "写入完成,校验已跳过。" + if "secure boot detected" in lowered or "secure download mode is enabled" in lowered: + return "检测到写入限制,请按工艺选择模式。" + for token, value in device_tokens.items(): + text = text.replace(token, value) + return text diff --git a/tools/esptool-factory/factory_gui/serial_utils.py b/tools/esptool-factory/factory_gui/serial_utils.py new file mode 100644 index 0000000..ea3d276 --- /dev/null +++ b/tools/esptool-factory/factory_gui/serial_utils.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from tkinter import messagebox + +from .path_utils import get_base_dir +from .tooling import run_command + + +def ensure_pyserial(python_cmd: Optional[Path], show_dialog: bool) -> bool: + try: + import serial # noqa: F401 + + return True + except Exception: + if not show_dialog: + return False + if python_cmd is None: + messagebox.showerror("缺少依赖", "内置环境缺少 pyserial,请重新打包。") + return False + if not messagebox.askyesno("缺少依赖", "未检测到 pyserial,是否自动安装?"): + return False + cmd = [str(python_cmd), "-m", "pip", "install", "pyserial"] + code, output = run_command(cmd, cwd=get_base_dir(), stream=False) + if code != 0: + messagebox.showerror( + "安装失败", + "pyserial 安装失败,请手动执行:\n" + f"{python_cmd} -m pip install pyserial\n\n{output}", + ) + return False + messagebox.showinfo("安装完成", "pyserial 已安装,请重试检测。") + return True + + +def list_serial_ports(python_cmd: Optional[Path], show_dialog: bool) -> list[str]: + if not ensure_pyserial(python_cmd, show_dialog): + return [] + from serial.tools import list_ports + + return [p.device for p in list_ports.comports()] + + +def pick_preferred_port(ports: list[str]) -> str: + if not ports: + return "" + hints = ("wchusbserial", "usbserial", "ttyusb", "ttyacm", "com") + for hint in hints: + for port in ports: + if hint in port.lower(): + return port + return ports[0] diff --git a/tools/esptool-factory/factory_gui/time_utils.py b/tools/esptool-factory/factory_gui/time_utils.py new file mode 100644 index 0000000..1b8bdb0 --- /dev/null +++ b/tools/esptool-factory/factory_gui/time_utils.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_iso_time(value: str) -> Optional[datetime]: + if not isinstance(value, str) or not value.strip(): + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _coerce_epoch_seconds(value: object) -> Optional[int]: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + stripped = value.strip() + if stripped.isdigit(): + return int(stripped) + return None diff --git a/tools/esptool-factory/factory_gui/tooling.py b/tools/esptool-factory/factory_gui/tooling.py new file mode 100644 index 0000000..99cac6a --- /dev/null +++ b/tools/esptool-factory/factory_gui/tooling.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import contextlib +import io +import os +import runpy +import subprocess +import sys +from pathlib import Path +from typing import Callable, Iterable, Optional, Tuple + + +@contextlib.contextmanager +def pushd(path: Path): + current = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(current) + + +class StreamCapture: + def __init__(self, on_output: Optional[Callable[[str], None]], collect: io.StringIO) -> None: + self.on_output = on_output + self.collect = collect + self.buffer = "" + + def write(self, text: str) -> int: + if text is None: + return 0 + text = str(text) + self.collect.write(text) + if self.on_output: + text = text.replace("\r", "\n") + self.buffer += text + while "\n" in self.buffer: + line, self.buffer = self.buffer.split("\n", 1) + if line: + self.on_output(line) + return len(text) + + def flush(self) -> None: + if self.on_output and self.buffer: + self.on_output(self.buffer) + self.buffer = "" + + @property + def encoding(self) -> str: + return "utf-8" + + @property + def errors(self) -> str: + return "replace" + + def isatty(self) -> bool: + return False + + def writable(self) -> bool: + return True + + def fileno(self) -> int: + raise OSError("StreamCapture has no file descriptor") + + +def run_tool_module( + tool: str, + args: Iterable[str], + cwd: Path, + stream: bool, + on_output: Optional[Callable[[str], None]], +) -> Tuple[int, str]: + argv_backup = sys.argv[:] + prev_rich_disable = os.environ.get("RICH_DISABLE") + prev_rich_click_disable = os.environ.get("RICH_CLICK_DISABLE") + output = io.StringIO() + capture = StreamCapture(on_output if stream else None, output) + try: + sys.argv = [tool, *list(args)] + os.environ["RICH_DISABLE"] = "1" + os.environ["RICH_CLICK_DISABLE"] = "1" + with pushd(cwd), contextlib.redirect_stdout(capture), contextlib.redirect_stderr(capture): + try: + module_name = f"{tool}.__main__" + try: + runpy.run_module(module_name, run_name="__main__") + except ModuleNotFoundError as exc: + if exc.name in (module_name, tool): + runpy.run_module(tool, run_name="__main__") + else: + raise + code = 0 + except SystemExit as exc: + code = int(exc.code) if isinstance(exc.code, int) else 0 + except Exception: + import traceback + + traceback.print_exc() + code = 1 + finally: + capture.flush() + sys.argv = argv_backup + if prev_rich_disable is None: + os.environ.pop("RICH_DISABLE", None) + else: + os.environ["RICH_DISABLE"] = prev_rich_disable + if prev_rich_click_disable is None: + os.environ.pop("RICH_CLICK_DISABLE", None) + else: + os.environ["RICH_CLICK_DISABLE"] = prev_rich_click_disable + return code, output.getvalue() + + +def run_esptool_api( + func: Callable[[], None], + stream: bool, + on_output: Optional[Callable[[str], None]], +) -> Tuple[int, str]: + output = io.StringIO() + capture = StreamCapture(on_output if stream else None, output) + try: + with contextlib.redirect_stdout(capture), contextlib.redirect_stderr(capture): + func() + code = 0 + except SystemExit as exc: + code = int(exc.code) if isinstance(exc.code, int) else 1 + except Exception: + import traceback + + traceback.print_exc() + code = 1 + finally: + capture.flush() + return code, output.getvalue() + + +def run_command( + cmd: Iterable[str], + cwd: Path, + stream: bool = False, + on_output: Optional[Callable[[str], None]] = None, +) -> Tuple[int, str]: + if stream: + proc = subprocess.Popen( + list(cmd), + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + output_parts = [] + assert proc.stdout is not None + for line in proc.stdout: + output_parts.append(line) + if on_output: + on_output(line.rstrip("\n")) + proc.wait() + return proc.returncode, "".join(output_parts) + + result = subprocess.run(list(cmd), cwd=cwd, capture_output=True, text=True) + combined = (result.stdout or "") + (result.stderr or "") + if on_output and combined: + for line in combined.splitlines(): + on_output(line) + return result.returncode, combined diff --git a/tools/esptool-factory/factory_private_config.py b/tools/esptool-factory/factory_private_config.py new file mode 100644 index 0000000..6f9e016 --- /dev/null +++ b/tools/esptool-factory/factory_private_config.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, Iterable, Optional, Tuple + +from factory_common.signing import SignatureError, verify_file_signature + + +DEFAULT_CONFIG_NAME = "factory_private.json" +CONF_DIR_NAME = "conf" +CONFIG_ENV_KEYS: Tuple[str, ...] = ("FACTORY_PRIVATE_CONFIG", "FACTORY_PRIVATE_CONFIG_PATH") +CACHE_DIR_NAME = "talkingq-factory" +CACHE_FILENAME = "factory_private_path.txt" +_MISSING = object() +_ENC_MAGIC = b"FENC" + + +class EncryptedConfigError(Exception): + pass + + +def resolve_private_config_path(base_dir: Path, allow_plain: bool = True) -> Optional[Path]: + for key in CONFIG_ENV_KEYS: + value = os.getenv(key) + if value: + return Path(value).expanduser() + candidate = base_dir / CONF_DIR_NAME / DEFAULT_CONFIG_NAME + enc_candidate = Path(str(candidate) + ".enc") + if allow_plain and candidate.exists(): + return candidate + if enc_candidate.exists(): + return enc_candidate + return None + + +def resolve_private_config_source_path(base_dir: Path) -> Optional[Path]: + for key in CONFIG_ENV_KEYS: + value = os.getenv(key) + if value: + path = Path(value).expanduser() + if path.suffix == ".enc": + plain = Path(str(path)[:-4]) + if plain.exists(): + return plain + return path + candidate = base_dir / CONF_DIR_NAME / DEFAULT_CONFIG_NAME + if candidate.exists(): + return candidate + enc_candidate = Path(str(candidate) + ".enc") + if enc_candidate.exists(): + return enc_candidate + return None + + +def is_frozen() -> bool: + if getattr(sys, "frozen", False): + return True + if getattr(sys, "nuitka_version", None): + return True + if globals().get("__compiled__") is not None: + return True + return "__compiled__" in sys.modules + + +def _cache_dir() -> Path: + if os.name == "nt": + base = os.getenv("APPDATA") or os.getenv("LOCALAPPDATA") + if base: + return Path(base) / CACHE_DIR_NAME + return Path.home() / "AppData" / "Roaming" / CACHE_DIR_NAME + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / CACHE_DIR_NAME + base = os.getenv("XDG_STATE_HOME") or os.getenv("XDG_CONFIG_HOME") + if base: + return Path(base) / CACHE_DIR_NAME + return Path.home() / ".config" / CACHE_DIR_NAME + + +def _cache_path() -> Path: + return _cache_dir() / CACHE_FILENAME + + +def _load_cached_path() -> Optional[Path]: + path = _cache_path() + try: + raw = path.read_text(encoding="utf-8").strip() + except OSError: + return None + if not raw: + return None + return Path(raw).expanduser() + + +def _save_cached_path(path: Path) -> None: + cache_dir = _cache_dir() + try: + cache_dir.mkdir(parents=True, exist_ok=True) + _cache_path().write_text(str(path), encoding="utf-8") + except OSError: + return + + +def _prompt_private_config_path(title: str, initial_dir: Optional[Path] = None) -> Optional[Path]: + try: + import tkinter as tk + from tkinter import filedialog + except Exception: + return None + root = tk.Tk() + root.withdraw() + try: + root.attributes("-topmost", True) + except Exception: + pass + dialog_args = { + "title": title, + "filetypes": [("JSON", "*.json"), ("All", "*.*")], + } + if initial_dir: + dialog_args["initialdir"] = str(initial_dir) + path = filedialog.askopenfilename(**dialog_args) + root.destroy() + if not path: + return None + return Path(path).expanduser() + + +def ensure_private_config_path(base_dir: Path, prompt_title: str = "选择工厂私有配置文件") -> Path: + path = resolve_private_config_path(base_dir) + if path is not None and path.exists(): + _save_cached_path(path) + return path + cached = _load_cached_path() + if cached is not None and cached.exists(): + os.environ["FACTORY_PRIVATE_CONFIG"] = str(cached) + return cached + initial_dir = cached.parent if cached else None + selected = _prompt_private_config_path(prompt_title, initial_dir=initial_dir) + if selected is None: + raise FileNotFoundError( + "缺少工厂私有配置文件。请设置环境变量 " + f"{'/'.join(CONFIG_ENV_KEYS)} 或在弹窗中选择配置文件。" + ) + os.environ["FACTORY_PRIVATE_CONFIG"] = str(selected) + _save_cached_path(selected) + return selected + + +def _is_encrypted_payload(payload: bytes) -> bool: + return payload.startswith(_ENC_MAGIC) + + +def _load_private_config_from_path( + path: Path, + verify_signature: bool = True, +) -> Dict[str, Any]: + if verify_signature: + try: + verify_file_signature(path) + except SignatureError as exc: + raise PermissionError(f"私有配置签名校验失败:{exc}") from exc + try: + payload = path.read_bytes() + except OSError as exc: # noqa: BLE001 + raise FileNotFoundError(f"无法读取私有配置文件:{path}") from exc + if _is_encrypted_payload(payload): + raise EncryptedConfigError("私有配置已加密。当前版本仅支持明文配置文件。") + else: + try: + raw = payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"私有配置文件格式错误:{path}") from exc + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"私有配置文件格式错误:{path}") from exc + if not isinstance(data, dict): + raise ValueError("私有配置文件根节点必须是对象。") + return data + + +def load_private_config(base_dir: Path) -> Dict[str, Any]: + path = resolve_private_config_path(base_dir, allow_plain=True) + if path is None or not path.exists(): + raise FileNotFoundError( + "缺少工厂私有配置文件。请设置环境变量 " + f"{'/'.join(CONFIG_ENV_KEYS)} 指定配置路径。" + ) + return _load_private_config_from_path(path) + + +def load_private_config_from_path(path: Path) -> Dict[str, Any]: + return _load_private_config_from_path(path) + + +def load_private_config_source(base_dir: Path) -> Dict[str, Any]: + path = resolve_private_config_source_path(base_dir) + if path is None or not path.exists(): + raise FileNotFoundError( + "缺少工厂私有配置文件。请设置环境变量 " + f"{'/'.join(CONFIG_ENV_KEYS)} 指定配置路径。" + ) + if path.suffix == ".enc": + raise EncryptedConfigError( + "私有配置源需要明文 factory_private.json(打包用)。请提供明文配置文件。" + ) + return _load_private_config_from_path( + path, + verify_signature=False, + ) + + +def _walk(data: Dict[str, Any], keys: Iterable[str]) -> Any: + current: Any = data + for key in keys: + if not isinstance(current, dict) or key not in current: + return _MISSING + current = current[key] + return current + + +def get_required( + data: Dict[str, Any], + keys: Iterable[str], + expected_type: Optional[tuple[type, ...]] = None, + allow_empty: bool = False, +) -> Any: + value = _walk(data, keys) + if value is _MISSING: + raise KeyError("缺少私有配置项: " + ".".join(keys)) + if expected_type and not isinstance(value, expected_type): + raise TypeError("私有配置类型错误: " + ".".join(keys)) + if not allow_empty and isinstance(value, str) and not value.strip(): + raise ValueError("私有配置不能为空: " + ".".join(keys)) + return value + + +def get_optional(data: Dict[str, Any], keys: Iterable[str], default: Any = None) -> Any: + value = _walk(data, keys) + if value is _MISSING: + return default + return value diff --git a/tools/esptool-factory/flash_encrypted_gui.py b/tools/esptool-factory/flash_encrypted_gui.py new file mode 100644 index 0000000..2ac5410 --- /dev/null +++ b/tools/esptool-factory/flash_encrypted_gui.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +""" +TalkingQ Drawing Robot - Factory Flashing GUI (local-only). + +Minimal Tkinter GUI to avoid Windows batch quirks. Relies on python -m esptool / +espefuse, and bin files placed in the same folder as this script / exe. +Designed to be PyInstaller-friendly. +""" +from __future__ import annotations + +import sys + +from factory_gui.app import main + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(1) diff --git a/tools/esptool-factory/keys/README.md b/tools/esptool-factory/keys/README.md new file mode 100644 index 0000000..f90d82f --- /dev/null +++ b/tools/esptool-factory/keys/README.md @@ -0,0 +1,15 @@ +Factory and build-time keys (local-only flow) + +This toolkit uses local keys only. + +Current usage: + +- `factory_signing/factory_signing_key.pem` + Used to sign packaged artifacts (`.bin`, config files). +- `factory_signing/factory_signing_pubkey.pem` + Public key used by the flashing tool for signature verification. +- `firmware/fw_key.bin` + 32-byte firmware envelope key used for `.bin.enc` encrypt/decrypt. + It is copied into release package as `keys/firmware/fw_key.bin`. + +Only the key files listed above are required in this repository. diff --git a/tools/esptool-factory/package_factory_tools.py b/tools/esptool-factory/package_factory_tools.py new file mode 100644 index 0000000..2e16dd2 --- /dev/null +++ b/tools/esptool-factory/package_factory_tools.py @@ -0,0 +1,806 @@ +#!/usr/bin/env python3 +""" +Package the factory flashing GUI into a distributable local-only app. + +- No network dependency. +- Always packages encrypted firmware envelope (.bin.enc). +- Preserves local signature verification assets for packaged files. +""" +from __future__ import annotations + +import argparse +import os +import shutil +import stat +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional + +from factory_common.firmware_envelope import ( + FirmwareEnvelopeError, + encrypt_firmware_blob, + load_firmware_encrypt_key, +) +from factory_common.idf_artifacts import sync_bins_from_idf_build +from factory_common.signing import sign_file, sig_path_for +from factory_private_config import get_required, load_private_config_source, resolve_private_config_path + +ROOT = Path(__file__).resolve().parent +REPO_ROOT = ROOT.parent.parent +VENV_DIR = ROOT / ".venv" +DIST_DIR = ROOT / "dist" +BUILD_DIR = ROOT / "build" +FACTORY_APP_NAME = "TalkingQ_Factory" +REQUIREMENTS = ROOT / "requirements.txt" +PYINSTALLER_CACHE_DIR = ROOT / ".pyinstaller" +FACTORY_ENTRY = ROOT / "flash_encrypted_gui.py" +ICON_FILE = ROOT / "talkingq_logo_256x256.ico" +ICON_ICNS = ROOT / "talkingq_logo_256x256.icns" +CLEAN_OLD = True +CONF_DIR_NAME = "conf" +SIGNING_KEY_ENV = "FACTORY_SIGNING_KEY_PATH" +SIGNING_KEY_PASSWORD_ENV = "FACTORY_SIGNING_KEY_PASSWORD" +SIGNING_PUBKEY_ENV = "FACTORY_SIGNING_PUBKEY_PATH" +DEFAULT_SIGNING_PUBKEY = ROOT / "keys" / "factory_signing" / "factory_signing_pubkey.pem" +DEFAULT_SIGNING_KEY = ROOT / "keys" / "factory_signing" / "factory_signing_key.pem" +DEFAULT_FIRMWARE_KEY = ROOT / "keys" / "firmware" / "fw_key.bin" +DEFAULT_IDF_BUILD_DIR = REPO_ROOT / "build" + +_ALLOWED_FLASH_MODE = {"qio", "qout", "dio", "dout", "keep"} +_ALLOWED_FLASH_FREQ = { + "keep", + "80m", + "60m", + "48m", + "40m", + "30m", + "26m", + "24m", + "20m", + "16m", + "15m", + "12m", +} +_ALLOWED_FLASH_SIZE = { + "keep", + "detect", + "256kb", + "512kb", + "1mb", + "2mb", + "2mb-c1", + "4mb", + "4mb-c1", + "8mb", + "16mb", + "32mb", + "64mb", + "128mb", +} +_ALLOWED_FLASH_BEFORE = {"default-reset", "usb-reset", "no-reset", "no-reset-no-sync"} +_ALLOWED_FLASH_AFTER = { + "default-reset", + "hard-reset", + "soft-reset", + "watchdog-reset", + "no-reset", + "no-reset-stub", + "noreset", + "none", + "no", +} + + +@dataclass(frozen=True) +class ToolSpec: + name: str + entry: Path + app_name: str + hidden_imports: tuple[str, ...] = () + collect_all: tuple[str, ...] = () + + +FACTORY_TOOL = ToolSpec( + name="factory", + entry=FACTORY_ENTRY, + app_name=FACTORY_APP_NAME, + hidden_imports=( + "esptool", + "esptool.__main__", + "espefuse", + "espefuse.__main__", + "serial", + "serial.tools.list_ports", + ), + collect_all=("esptool", "espefuse", "cryptography"), +) + + +def _rel(path: Path) -> str: + try: + return str(path.relative_to(ROOT)) + except ValueError: + return path.name + + +def _parse_bin_encryption(flash_cfg: dict) -> None: + enc_cfg = flash_cfg.get("bin_encryption") + if enc_cfg is not None: + raise ValueError("私有配置无效: flash.bin_encryption 已移除。") + + +def _coerce_str(value: object, label: str) -> str: + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str) and value.strip(): + return value.strip() + raise ValueError(f"私有配置无效: {label}") + + +def _coerce_offset_int(value: str, label: str) -> int: + try: + return int(value, 0) + except ValueError as exc: + raise ValueError(f"私有配置无效: {label}") from exc + + +def _validate_allowed( + value: str, + label: str, + allowed: set[str], + *, + normalize_dash: bool = False, +) -> str: + normalized = value.strip().lower() + if normalize_dash: + normalized = normalized.replace("_", "-") + if normalized not in allowed: + allowed_text = ", ".join(sorted(allowed)) + raise ValueError(f"私有配置无效: {label}={value}(允许: {allowed_text})") + return normalized + + +def _validate_app_layout_binding( + layout: list[tuple[str, str]], + app_bin_name: str, + app_partition_offset: str, +) -> None: + app_offsets = [offset for offset, name in layout if name == app_bin_name] + if not app_offsets: + raise ValueError("私有配置无效: flash.app_bin_name 必须出现在 flash.layout") + + app_layout_offsets = {_coerce_offset_int(offset, "flash.layout.offset") for offset in app_offsets} + if len(app_layout_offsets) != 1: + raise ValueError("私有配置无效: flash.layout 中 app_bin_name 对应多个偏移地址") + + app_offset = _coerce_offset_int(app_partition_offset, "flash.app_partition_offset") + if app_offset not in app_layout_offsets: + layout_offset = app_offsets[0] + raise ValueError( + "私有配置无效: flash.app_partition_offset 与 flash.layout 中 app_bin_name 的 offset 不一致" + f"(app_partition_offset={app_partition_offset}, layout_offset={layout_offset})" + ) + + +def _validate_flash_args(flash_cfg: dict) -> None: + flash_args_raw = get_required(flash_cfg, ("flash_args",), (dict,)) + _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("flash_mode",), (str, int)), + "flash.flash_args.flash_mode", + ), + "flash.flash_args.flash_mode", + _ALLOWED_FLASH_MODE, + ) + _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("flash_freq",), (str, int)), + "flash.flash_args.flash_freq", + ), + "flash.flash_args.flash_freq", + _ALLOWED_FLASH_FREQ, + ) + _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("flash_size",), (str, int)), + "flash.flash_args.flash_size", + ), + "flash.flash_args.flash_size", + _ALLOWED_FLASH_SIZE, + ) + _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("before",), (str, int)), + "flash.flash_args.before", + ), + "flash.flash_args.before", + _ALLOWED_FLASH_BEFORE, + normalize_dash=True, + ) + _validate_allowed( + _coerce_str( + get_required(flash_args_raw, ("after",), (str, int)), + "flash.flash_args.after", + ), + "flash.flash_args.after", + _ALLOWED_FLASH_AFTER, + normalize_dash=True, + ) + + +def _load_flash_inputs() -> tuple[Path, list[str]]: + raw = load_private_config_source(ROOT) + flash_cfg = get_required(raw, ("flash",), (dict,)) + bin_dir_raw = get_required(flash_cfg, ("bin_dir",), (str,)) + bin_dir = Path(bin_dir_raw) + if not bin_dir.is_absolute(): + bin_dir = ROOT / bin_dir + + _parse_bin_encryption(flash_cfg) + + layout_raw = get_required(flash_cfg, ("layout",), (list,)) + layout: list[tuple[str, str]] = [] + required_bins: list[str] = [] + for entry in layout_raw: + offset: Optional[str] = None + name: Optional[str] = None + if isinstance(entry, (list, tuple)) and len(entry) == 2: + offset = _coerce_str(entry[0], "flash.layout.offset") + name = _coerce_str(entry[1], "flash.layout.name") + elif isinstance(entry, dict): + offset = _coerce_str(entry.get("offset"), "flash.layout.offset") + name = _coerce_str(entry.get("name"), "flash.layout.name") + else: + raise ValueError("私有配置无效: flash.layout") + clean_name = name.strip() + layout.append((offset, clean_name)) + if clean_name.endswith(".enc"): + raise ValueError("私有配置无效: flash.layout 不能包含 .enc 文件") + required_bins.append(clean_name) + + if not required_bins: + raise ValueError("私有配置无效: flash.layout 不能为空") + + app_bin_name = _coerce_str( + get_required(flash_cfg, ("app_bin_name",), (str,)), + "flash.app_bin_name", + ) + if app_bin_name.endswith(".enc"): + raise ValueError("私有配置无效: flash.app_bin_name 不应包含 .enc 后缀。") + app_partition_offset = _coerce_str( + get_required(flash_cfg, ("app_partition_offset",), (str, int)), + "flash.app_partition_offset", + ) + _validate_app_layout_binding(layout, app_bin_name, app_partition_offset) + _validate_flash_args(flash_cfg) + return bin_dir, required_bins + + +def print_step(step: int, total: int, message: str) -> None: + print(f"[步骤 {step}/{total}] {message}") + + +def venv_python() -> Path: + scripts = "Scripts" if os.name == "nt" else "bin" + exe = "python.exe" if os.name == "nt" else "python3" + return VENV_DIR / scripts / exe + + +def run(cmd: Iterable[str]) -> None: + cmd_list = [str(c) for c in cmd] + display_cmd = [] + for item in cmd_list: + if item.startswith(str(ROOT)): + display_cmd.append(_rel(Path(item))) + elif item.endswith("python") or item.endswith("python3") or item.endswith("python.exe"): + display_cmd.append("python") + else: + display_cmd.append(item) + print(">>", " ".join(display_cmd)) + env = os.environ.copy() + env["PYINSTALLER_CONFIG_DIR"] = str(PYINSTALLER_CACHE_DIR) + result = subprocess.run(cmd_list, env=env) + if result.returncode != 0: + raise SystemExit(result.returncode) + + +def ensure_venv() -> None: + if venv_python().exists(): + return + raise FileNotFoundError( + "Missing virtualenv at " + f"{VENV_DIR}. Create it and install deps:\n" + f" python3 -m venv {VENV_DIR}\n" + f" {venv_python()} -m pip install -r {REQUIREMENTS}" + ) + + +def clean_old(enabled: bool) -> None: + if not enabled: + return + for path in (BUILD_DIR, DIST_DIR): + if path.exists(): + _make_tree_writable(path) + print(f"[INFO] Removing {_rel(path)}") + _rmtree(path) + + +def ensure_entry_exists(entry: Path) -> None: + if not entry.exists(): + raise FileNotFoundError(f"Missing required input: {_rel(entry)}") + + +def ensure_flash_files(bin_dir: Path, required_bins: Iterable[str]) -> None: + missing = [str(p) for p in [FACTORY_ENTRY, bin_dir] if not p.exists()] + if missing: + raise FileNotFoundError( + "Missing required inputs:\n " + "\n ".join(_rel(Path(p)) for p in missing) + ) + + missing_bins: list[str] = [] + for name in required_bins: + path = bin_dir / name + if not path.exists(): + missing_bins.append(_rel(path)) + + if missing_bins: + raise FileNotFoundError( + "Missing required stage binaries:\n " + "\n ".join(missing_bins) + ) + + +def _sync_bins_from_build( + bin_dir: Path, + required_bins: Iterable[str], + build_dir: Path, + enabled: bool, +) -> None: + if not enabled: + return + copied = sync_bins_from_idf_build(bin_dir, required_bins, build_dir) + for source, target in copied: + print(f"[INFO] Sync build bin: {source} -> {_rel(target)}") + + +def _resolve_icon() -> Optional[Path]: + if sys.platform == "darwin": + return ICON_ICNS if ICON_ICNS.exists() else None + return ICON_FILE if ICON_FILE.exists() else None + + +def _pyinstaller_mode_args() -> list[str]: + if sys.platform == "darwin": + return ["--onedir"] + return ["--onefile"] + + +def build_exe(tool: ToolSpec) -> None: + cmd = [ + str(venv_python()), + "-m", + "PyInstaller", + "--noconsole", + "--clean", + ] + cmd.extend(_pyinstaller_mode_args()) + + icon_path = _resolve_icon() + if icon_path is None and sys.platform == "darwin": + print("[WARN] macOS requires .icns icon; skipping icon (no .icns found).") + if icon_path is not None: + cmd.extend(["--icon", str(icon_path)]) + cmd.extend(["--add-data", f"{icon_path}{os.pathsep}."]) + + for hidden in tool.hidden_imports: + cmd.extend(["--hidden-import", hidden]) + for pkg in tool.collect_all: + cmd.extend(["--collect-all", pkg]) + + cmd.extend( + [ + "--name", + tool.app_name, + "--distpath", + str(DIST_DIR), + "--workpath", + str(BUILD_DIR), + "--specpath", + str(ROOT), + str(tool.entry), + ] + ) + run(cmd) + + +def _resolve_private_config_for_signing(base_dir: Path) -> Optional[Path]: + env_path = os.getenv("FACTORY_PRIVATE_CONFIG_PATH") or os.getenv("FACTORY_PRIVATE_CONFIG") + if env_path: + path = Path(env_path).expanduser() + if path.suffix == ".enc": + plain = Path(str(path)[:-4]) + if plain.exists(): + return plain + return path + + candidate = base_dir / CONF_DIR_NAME / "factory_private.json" + if candidate.exists(): + return candidate + return resolve_private_config_path(base_dir) + + +def _set_readonly(path: Path) -> None: + try: + mode = path.stat().st_mode + if os.name == "nt": + path.chmod(mode & ~stat.S_IWRITE) + else: + path.chmod(mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH) + except OSError: + pass + + +def _clear_readonly(path: Path) -> None: + try: + mode = path.stat().st_mode + path.chmod(mode | stat.S_IWRITE) + except OSError: + pass + + +def _rmtree(path: Path) -> None: + def _on_error(func, failed_path, _exc_info): # type: ignore[no-untyped-def] + try: + os.chmod(failed_path, stat.S_IWRITE) + try: + func(failed_path) + except TypeError: + return + except Exception: + pass + + shutil.rmtree(path, onerror=_on_error) + + +def _make_tree_writable(path: Path) -> None: + try: + if path.exists(): + for root, dirs, files in os.walk(path): + for name in dirs: + try: + os.chmod(Path(root) / name, stat.S_IRWXU) + except OSError: + pass + for name in files: + try: + os.chmod(Path(root) / name, stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + os.chmod(path, stat.S_IRWXU) + except OSError: + pass + + +def _copy_file(src: Path, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + + +def _resolve_signing_key() -> Optional[Path]: + key_path = os.getenv(SIGNING_KEY_ENV) + if not key_path: + return DEFAULT_SIGNING_KEY if DEFAULT_SIGNING_KEY.exists() else None + return Path(key_path).expanduser() + + +def _resolve_signing_pubkey() -> Path: + pubkey_path = os.getenv(SIGNING_PUBKEY_ENV) + if pubkey_path: + return Path(pubkey_path).expanduser() + return DEFAULT_SIGNING_PUBKEY + + +def _copy_signing_pubkey(dest_root: Path) -> None: + pubkey_path = _resolve_signing_pubkey() + if not pubkey_path.exists(): + raise FileNotFoundError(f"Missing signing public key: {pubkey_path}") + + dest = dest_root / "keys" / "factory_signing" / "factory_signing_pubkey.pem" + _copy_file(pubkey_path, dest) + _set_readonly(dest) + + +def _copy_firmware_key(dest_root: Path) -> None: + if not DEFAULT_FIRMWARE_KEY.exists(): + raise FileNotFoundError(f"Missing firmware key: {DEFAULT_FIRMWARE_KEY}") + + dest = dest_root / "keys" / "firmware" / "fw_key.bin" + _copy_file(DEFAULT_FIRMWARE_KEY, dest) + _set_readonly(dest) + + +def _resolve_packaged_app(tool: ToolSpec) -> tuple[Path, bool]: + candidates = [ + DIST_DIR / f"{tool.app_name}.exe", + DIST_DIR / f"{tool.app_name}.app", + DIST_DIR / tool.app_name, + ] + for path in candidates: + if path.exists(): + return path, path.is_dir() + raise FileNotFoundError( + f"Missing packaged app. Expected one of: {', '.join(str(p) for p in candidates)}" + ) + + +def _cleanup_root_artifacts(tool: ToolSpec) -> None: + for path in ( + DIST_DIR / f"{tool.app_name}.exe", + DIST_DIR / f"{tool.app_name}.app", + DIST_DIR / tool.app_name, + ): + if not path.exists(): + continue + try: + if path.is_dir(): + _rmtree(path) + else: + _clear_readonly(path) + path.unlink() + print(f"[INFO] Removed root artifact: {_rel(path)}") + except OSError: + print(f"[WARN] Failed to remove root artifact: {_rel(path)}") + + +def _copy_packaged_app(src: Path, is_dir: bool, dest_dir: Path) -> None: + dest = dest_dir / src.name + if is_dir: + if dest.exists(): + _rmtree(dest) + shutil.copytree(src, dest) + else: + shutil.copy2(src, dest) + + +def _enc_name_for(name: str) -> str: + return f"{name}.enc" + + +def _encrypt_firmware_bins( + source_bin_dir: Path, + required_bins: Iterable[str], + enc_key: bytes, + out_bin_dir: Path, +) -> None: + missing: list[Path] = [] + out_bin_dir.mkdir(parents=True, exist_ok=True) + for name in required_bins: + plain_path = source_bin_dir / name + if not plain_path.exists(): + missing.append(plain_path) + continue + try: + plain = plain_path.read_bytes() + except OSError as exc: + raise OSError(f"读取固件失败: {plain_path}") from exc + enc_path = out_bin_dir / _enc_name_for(name) + enc_payload = encrypt_firmware_blob(plain, enc_key) + enc_path.write_bytes(enc_payload) + + if missing: + raise FileNotFoundError( + "Missing required binaries for encryption:\n " + "\n ".join(_rel(p) for p in missing) + ) + + +def _sign_encrypted_firmware_bins(enc_bin_dir: Path, required_bins: Iterable[str], key_path: Path) -> None: + if not key_path.exists(): + raise FileNotFoundError(f"Missing signing key: {key_path}") + + password = os.getenv(SIGNING_KEY_PASSWORD_ENV) + missing: list[Path] = [] + for name in required_bins: + enc_path = enc_bin_dir / _enc_name_for(name) + if not enc_path.exists(): + missing.append(enc_path) + continue + sign_file(enc_path, key_path, password=password) + + if missing: + raise FileNotFoundError( + "Missing encrypted binaries for signing:\n " + "\n ".join(_rel(p) for p in missing) + ) + + +def _copy_required_file(src: Path, dest_root: Path) -> None: + if not src.exists(): + raise FileNotFoundError(f"Missing file for packaging: {src}") + dest = dest_root / src.name + _copy_file(src, dest) + _set_readonly(dest) + + +def copy_support_files( + bin_dir_name: str, + dest_dir: Path, + required_bins: Iterable[str], + enc_bin_dir: Path, +) -> None: + dest = dest_dir / bin_dir_name + if dest.exists(): + _rmtree(dest) + dest.mkdir(parents=True, exist_ok=True) + print(f"[INFO] Copy selected firmware files -> {_rel(dest)}/") + + for name in required_bins: + enc_path = enc_bin_dir / _enc_name_for(name) + enc_sig = sig_path_for(enc_path) + _copy_required_file(enc_path, dest) + _copy_required_file(enc_sig, dest) + + +def _bundle_private_config(cfg_path: Path, dest_dir: Path) -> None: + if not cfg_path.exists(): + raise FileNotFoundError("Missing private config for bundling") + + sig_path = sig_path_for(cfg_path) + if not sig_path.exists(): + raise FileNotFoundError(f"Missing config signature: {sig_path}") + + target_root = dest_dir / CONF_DIR_NAME + target_root.mkdir(parents=True, exist_ok=True) + for path in (cfg_path, sig_path): + dest = target_root / path.name + print(f"[INFO] Copy {path.name} -> {_rel(dest)}") + shutil.copy2(path, dest) + _set_readonly(dest) + + +def _warn_unexpected_dist_entries(dist_dir: Path, allowed_dirs: Iterable[str]) -> None: + if not dist_dir.exists(): + return + + allowed = set(allowed_dirs) + for entry in dist_dir.iterdir(): + if entry.is_dir() and entry.name not in allowed: + print( + "[WARN] Unexpected directory in dist: " + f"{_rel(entry)} (release should only include the current package folders)." + ) + + +def _sanitize_package_name(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in value.strip()) + return cleaned or "factory" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Package factory flashing tool.") + parser.add_argument( + "--no-clean", + action="store_true", + help="Keep existing build/dist outputs to reuse more cache.", + ) + parser.add_argument( + "--package-name", + default="factory", + help="Output package directory name under dist/ (default: factory).", + ) + parser.add_argument( + "--build-dir", + default=str(DEFAULT_IDF_BUILD_DIR), + help="ESP-IDF build directory for auto-syncing missing .bin files.", + ) + parser.add_argument( + "--no-sync-build-bins", + action="store_true", + help="Disable auto-sync of missing bins from build/flasher_args.json.", + ) + args = parser.parse_args() + + ensure_venv() + + targets = [FACTORY_TOOL] + total_steps = 5 + + print_step(1, total_steps, "检查输入与签名配置") + bin_dir, required_bins = _load_flash_inputs() + build_dir = Path(args.build_dir).expanduser() + _sync_bins_from_build( + bin_dir=bin_dir, + required_bins=required_bins, + build_dir=build_dir, + enabled=not args.no_sync_build_bins, + ) + ensure_flash_files(bin_dir, required_bins) + for tool in targets: + ensure_entry_exists(tool.entry) + + pubkey_path = _resolve_signing_pubkey() + if not pubkey_path.exists(): + raise FileNotFoundError(f"Missing signing public key: {pubkey_path}") + if not DEFAULT_FIRMWARE_KEY.exists(): + raise FileNotFoundError(f"Missing firmware key: {DEFAULT_FIRMWARE_KEY}") + + signing_key = _resolve_signing_key() + if signing_key is None or not signing_key.exists(): + raise FileNotFoundError( + "Missing signing key. Set FACTORY_SIGNING_KEY_PATH or provide " + f"{DEFAULT_SIGNING_KEY}." + ) + signing_password = os.getenv(SIGNING_KEY_PASSWORD_ENV) + package_name = _sanitize_package_name(args.package_name) + try: + enc_key = load_firmware_encrypt_key() + except FirmwareEnvelopeError as exc: + raise ValueError(f"固件封装模式(enc)初始化失败:{exc}") from exc + + with tempfile.TemporaryDirectory(prefix="talkingq_factory_pack_") as temp_dir: + staging_root = Path(temp_dir) + staging_conf_dir = staging_root / CONF_DIR_NAME + staging_enc_bin_dir = staging_root / "bin" + staging_conf_dir.mkdir(parents=True, exist_ok=True) + + private_source = _resolve_private_config_for_signing(ROOT) + if private_source is None or not private_source.exists(): + raise FileNotFoundError("Missing private config source: factory_private.json") + if private_source.suffix == ".enc": + raise ValueError("Encrypted private config is not supported.") + + private_cfg_path = staging_conf_dir / private_source.name + _copy_file(private_source, private_cfg_path) + sign_file(private_cfg_path, signing_key, password=signing_password) + print(f"[INFO] Signed private config -> {_rel(private_cfg_path)}") + + print_step(2, total_steps, "清理旧构建产物") + PYINSTALLER_CACHE_DIR.mkdir(parents=True, exist_ok=True) + clean_old(enabled=not args.no_clean and CLEAN_OLD) + DIST_DIR.mkdir(parents=True, exist_ok=True) + try: + os.chmod(DIST_DIR, stat.S_IRWXU) + except OSError: + pass + + print_step(3, total_steps, "构建打包文件") + for tool in targets: + print(f"[INFO] Build {tool.app_name}") + build_exe(tool) + + print_step(4, total_steps, "固件封装/签名") + _encrypt_firmware_bins(bin_dir, required_bins, enc_key, staging_enc_bin_dir) + _sign_encrypted_firmware_bins(staging_enc_bin_dir, required_bins, signing_key) + + print_step(5, total_steps, "生成产线包") + for tool in targets: + exe_path, exe_is_dir = _resolve_packaged_app(tool) + package_dir = DIST_DIR / package_name + if package_dir.exists(): + _rmtree(package_dir) + package_dir.mkdir(parents=True, exist_ok=True) + + _copy_packaged_app(exe_path, exe_is_dir, package_dir) + copy_support_files( + bin_dir_name=bin_dir.name, + dest_dir=package_dir, + required_bins=required_bins, + enc_bin_dir=staging_enc_bin_dir, + ) + _copy_firmware_key(package_dir) + _copy_signing_pubkey(package_dir) + _bundle_private_config(private_cfg_path, dest_dir=package_dir) + + print(f"[DONE] Package: {_rel(package_dir)}") + _cleanup_root_artifacts(tool) + + _warn_unexpected_dist_entries(DIST_DIR, [package_name]) + print("推荐操作:将 dist// 目录直接交付产线使用。") + + +if __name__ == "__main__": + try: + main() + except Exception as exc: # noqa: BLE001 + print(f"[FAIL] {exc}", file=sys.stderr) + sys.exit(1) diff --git a/tools/esptool-factory/requirements.txt b/tools/esptool-factory/requirements.txt new file mode 100644 index 0000000..21fdef4 --- /dev/null +++ b/tools/esptool-factory/requirements.txt @@ -0,0 +1,5 @@ +# Dependencies for flash_encrypted_gui.py packaging +esptool==5.1.0 +pyserial==3.5 +pyinstaller==6.18.0 +cryptography>=46.0.4 diff --git a/tools/esptool-factory/talkingq_logo_256x256.icns b/tools/esptool-factory/talkingq_logo_256x256.icns new file mode 100644 index 0000000..efb2d80 Binary files /dev/null and b/tools/esptool-factory/talkingq_logo_256x256.icns differ diff --git a/tools/esptool-factory/talkingq_logo_256x256.ico b/tools/esptool-factory/talkingq_logo_256x256.ico new file mode 100644 index 0000000..4e46c13 Binary files /dev/null and b/tools/esptool-factory/talkingq_logo_256x256.ico differ