From 3d3c3ebac2cd7b234ccd4dfd20deb1c7afc4b769 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 2 Mar 2026 01:57:39 +0800 Subject: [PATCH] feat(factory): add local flashing toolkit and secure OTA defaults --- .gitignore | 16 + partitions.csv | 12 +- sdkconfig.defaults | 15 + tools/esptool-factory/README.md | 79 + .../factory_common/__init__.py | 21 + .../factory_common/firmware_envelope.py | 153 ++ .../factory_common/idf_artifacts.py | 92 + .../esptool-factory/factory_common/signing.py | 182 ++ .../factory_common/signing_pubkey.py | 51 + tools/esptool-factory/factory_gui/__init__.py | 1 + tools/esptool-factory/factory_gui/app.py | 2316 +++++++++++++++++ tools/esptool-factory/factory_gui/config.py | 297 +++ .../esptool-factory/factory_gui/constants.py | 8 + .../factory_gui/flash_utils.py | 452 ++++ tools/esptool-factory/factory_gui/models.py | 53 + .../esptool-factory/factory_gui/path_utils.py | 74 + tools/esptool-factory/factory_gui/redact.py | 95 + .../factory_gui/serial_utils.py | 54 + .../esptool-factory/factory_gui/time_utils.py | 35 + tools/esptool-factory/factory_gui/tooling.py | 166 ++ .../esptool-factory/factory_private_config.py | 246 ++ tools/esptool-factory/flash_encrypted_gui.py | 20 + tools/esptool-factory/keys/README.md | 15 + .../esptool-factory/package_factory_tools.py | 806 ++++++ tools/esptool-factory/requirements.txt | 5 + .../talkingq_logo_256x256.icns | Bin 0 -> 25753 bytes .../esptool-factory/talkingq_logo_256x256.ico | Bin 0 -> 21639 bytes 27 files changed, 5260 insertions(+), 4 deletions(-) create mode 100644 tools/esptool-factory/README.md create mode 100644 tools/esptool-factory/factory_common/__init__.py create mode 100644 tools/esptool-factory/factory_common/firmware_envelope.py create mode 100644 tools/esptool-factory/factory_common/idf_artifacts.py create mode 100644 tools/esptool-factory/factory_common/signing.py create mode 100644 tools/esptool-factory/factory_common/signing_pubkey.py create mode 100644 tools/esptool-factory/factory_gui/__init__.py create mode 100644 tools/esptool-factory/factory_gui/app.py create mode 100644 tools/esptool-factory/factory_gui/config.py create mode 100644 tools/esptool-factory/factory_gui/constants.py create mode 100644 tools/esptool-factory/factory_gui/flash_utils.py create mode 100644 tools/esptool-factory/factory_gui/models.py create mode 100644 tools/esptool-factory/factory_gui/path_utils.py create mode 100644 tools/esptool-factory/factory_gui/redact.py create mode 100644 tools/esptool-factory/factory_gui/serial_utils.py create mode 100644 tools/esptool-factory/factory_gui/time_utils.py create mode 100644 tools/esptool-factory/factory_gui/tooling.py create mode 100644 tools/esptool-factory/factory_private_config.py create mode 100644 tools/esptool-factory/flash_encrypted_gui.py create mode 100644 tools/esptool-factory/keys/README.md create mode 100644 tools/esptool-factory/package_factory_tools.py create mode 100644 tools/esptool-factory/requirements.txt create mode 100644 tools/esptool-factory/talkingq_logo_256x256.icns create mode 100644 tools/esptool-factory/talkingq_logo_256x256.ico 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 0000000000000000000000000000000000000000..efb2d80ca5533483a307dc1bbf49c856bc2028cd GIT binary patch literal 25753 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ȳ=XPq{sRZWya2mcjcy_dg^IDdTc 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 a$ks*XE5vE?#KD=MkSA(7s@2MN;r{{A6!>QV literal 0 HcmV?d00001