feat(factory): add local flashing toolkit and secure OTA defaults
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user