feat(factory): add local flashing toolkit and secure OTA defaults

This commit is contained in:
admin
2026-03-02 01:57:39 +08:00
parent 809fcf6548
commit 3d3c3ebac2
27 changed files with 5260 additions and 4 deletions

16
.gitignore vendored
View File

@@ -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

View File

@@ -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,
1 # Name # ESP-IDF Partition Table Type SubType Offset Size Flags
2 nvs # Name, Type, SubType, Offset, Size, Flags data nvs 0x6000
3 phy_init nvs, data, nvs, 0x11000, 0x10000, data phy 0x1000
4 factory otadata, data, ota, 0x21000, 0x2000, app factory 4M
5 phy_init, data, phy, 0x23000, 0x1000,
6 ota_0, app, ota_0, 0x30000, 0x780000,
7 ota_1, app, ota_1, 0x7B0000, 0x780000,
8 nvs_key, data, nvs_keys,0xF30000, 0x1000, encrypted,

View File

@@ -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

View 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.

View 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",
]

View 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

View 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

View 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)

View 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()

View File

@@ -0,0 +1 @@
"""Factory flashing GUI modules."""

File diff suppressed because it is too large Load Diff

View 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,
)

View 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"))

View 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()

View 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]

View 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

View 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

View 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]

View 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

View 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

View 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

View 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)

View 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.

View 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)

View 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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB