feat(factory): add local flashing toolkit and secure OTA defaults
This commit is contained in:
16
.gitignore
vendored
16
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
@@ -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
|
||||
|
||||
79
tools/esptool-factory/README.md
Normal file
79
tools/esptool-factory/README.md
Normal file
@@ -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.
|
||||
21
tools/esptool-factory/factory_common/__init__.py
Normal file
21
tools/esptool-factory/factory_common/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
153
tools/esptool-factory/factory_common/firmware_envelope.py
Normal file
153
tools/esptool-factory/factory_common/firmware_envelope.py
Normal file
@@ -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
|
||||
92
tools/esptool-factory/factory_common/idf_artifacts.py
Normal file
92
tools/esptool-factory/factory_common/idf_artifacts.py
Normal file
@@ -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
|
||||
182
tools/esptool-factory/factory_common/signing.py
Normal file
182
tools/esptool-factory/factory_common/signing.py
Normal file
@@ -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)
|
||||
51
tools/esptool-factory/factory_common/signing_pubkey.py
Normal file
51
tools/esptool-factory/factory_common/signing_pubkey.py
Normal file
@@ -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()
|
||||
1
tools/esptool-factory/factory_gui/__init__.py
Normal file
1
tools/esptool-factory/factory_gui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Factory flashing GUI modules."""
|
||||
2316
tools/esptool-factory/factory_gui/app.py
Normal file
2316
tools/esptool-factory/factory_gui/app.py
Normal file
File diff suppressed because it is too large
Load Diff
297
tools/esptool-factory/factory_gui/config.py
Normal file
297
tools/esptool-factory/factory_gui/config.py
Normal file
@@ -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,
|
||||
)
|
||||
8
tools/esptool-factory/factory_gui/constants.py
Normal file
8
tools/esptool-factory/factory_gui/constants.py
Normal file
@@ -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"))
|
||||
452
tools/esptool-factory/factory_gui/flash_utils.py
Normal file
452
tools/esptool-factory/factory_gui/flash_utils.py
Normal file
@@ -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()
|
||||
53
tools/esptool-factory/factory_gui/models.py
Normal file
53
tools/esptool-factory/factory_gui/models.py
Normal file
@@ -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]
|
||||
74
tools/esptool-factory/factory_gui/path_utils.py
Normal file
74
tools/esptool-factory/factory_gui/path_utils.py
Normal file
@@ -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
|
||||
95
tools/esptool-factory/factory_gui/redact.py
Normal file
95
tools/esptool-factory/factory_gui/redact.py
Normal file
@@ -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("<mac>", text)
|
||||
text = _UUID_RE.sub("<id>", text)
|
||||
text = _URL_RE.sub("<url>", text)
|
||||
text = _JSON_KEY_RE.sub(
|
||||
lambda m: f"{m.group(1)}<redacted:{len(m.group(2))}>{m.group(3)}", text
|
||||
)
|
||||
text = _KV_RE.sub(
|
||||
lambda m: f"{m.group(1)}=<redacted:{len(m.group(2))}>", text
|
||||
)
|
||||
text = _HEX_BYTES_RE.sub("<redacted:hex>", text)
|
||||
text = _BIN_PATH_RE.sub(lambda m: os.path.basename(m.group(1)), text)
|
||||
text = _BIN_NAME_RE.sub("<bin>", 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
|
||||
54
tools/esptool-factory/factory_gui/serial_utils.py
Normal file
54
tools/esptool-factory/factory_gui/serial_utils.py
Normal file
@@ -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]
|
||||
35
tools/esptool-factory/factory_gui/time_utils.py
Normal file
35
tools/esptool-factory/factory_gui/time_utils.py
Normal file
@@ -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
|
||||
166
tools/esptool-factory/factory_gui/tooling.py
Normal file
166
tools/esptool-factory/factory_gui/tooling.py
Normal file
@@ -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
|
||||
246
tools/esptool-factory/factory_private_config.py
Normal file
246
tools/esptool-factory/factory_private_config.py
Normal file
@@ -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
|
||||
20
tools/esptool-factory/flash_encrypted_gui.py
Normal file
20
tools/esptool-factory/flash_encrypted_gui.py
Normal file
@@ -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)
|
||||
15
tools/esptool-factory/keys/README.md
Normal file
15
tools/esptool-factory/keys/README.md
Normal file
@@ -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.
|
||||
806
tools/esptool-factory/package_factory_tools.py
Normal file
806
tools/esptool-factory/package_factory_tools.py
Normal file
@@ -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/<package-name>/ 目录直接交付产线使用。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[FAIL] {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
5
tools/esptool-factory/requirements.txt
Normal file
5
tools/esptool-factory/requirements.txt
Normal file
@@ -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
|
||||
BIN
tools/esptool-factory/talkingq_logo_256x256.icns
Normal file
BIN
tools/esptool-factory/talkingq_logo_256x256.icns
Normal file
Binary file not shown.
BIN
tools/esptool-factory/talkingq_logo_256x256.ico
Normal file
BIN
tools/esptool-factory/talkingq_logo_256x256.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Reference in New Issue
Block a user