Files
simba-rmic-2026/scripts/package_firmware.py
T
sloray 054379dc64
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
fix(packaging): 修复 Windows 安装与固件打包并完善客户端文案
- fix(installer): 修复低完整性产物无法写入临时目录的问题,并在 CI 打包前后检查完整性标签
- fix(firmware): 从本次 Cargo ELF 打包并同步配套镜像,修正短目录说明与 LLVM 搜索顺序
- fix(installer): 修复中文完成页和页眉遮挡,侧栏使用等比缩放的原始 Logo
- style(client): 应用图标增加圆角,统一“裁判系统客户端”命名
- fix(config): 安装提示改用可选本地热点配置,排除 Wi-Fi 凭据
- docs: 润色项目说明并统一裁判系统术语
- test(packaging): 补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成

验证:Windows NSIS 构建及原生页面预览;通用与本地安装配置编译;固件 release 构建、重复打包和客户端离线校验;6 项固件打包回归与 Windows 权限回归;git diff --check。未执行实机烧录。
2026-10-21 09:25:17 +08:00

278 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""把编译后的固件目录打成客户端可直接读取的 .zip 官方包。
流程(在 cargo build 之后):
package_firmware.py
├─ 从 Cargo 产物导出 App,同步 Bootloader / 分区表
├─ 刷新 BUILD-INFO.json,并调用 make_bundle.py 生成 bundle.json
├─ 固定条目顺序与 mtime,生成可复现的 zip
└─ 写 <zip>.sha256
zip 根目录结构:
bundle.json
esp32s3-app.bin
partition-table.bin (0xC00 字节)
bootloader.bin (可选)
BUILD-INFO.json (可选)
客户端读取 zip 时不要求先解压;目录包仍然兼容。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import zipfile
from datetime import datetime, timezone
from pathlib import Path
def sanitize(value: str) -> str:
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
return value.strip("-") or "firmware"
def parse_epoch(build: dict) -> int:
raw = os.environ.get("SOURCE_DATE_EPOCH")
if raw:
try:
return int(raw)
except ValueError:
pass
packaged = build.get("packaged_at")
if packaged:
try:
return int(datetime.fromisoformat(packaged).timestamp())
except (ValueError, TypeError):
pass
return int(time.time())
def resolve_elf(repo: Path, explicit: str) -> Path:
if explicit:
return Path(explicit).resolve()
# Cargo metadata also respects build.target-dir in Cargo configuration.
result = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1"],
cwd=repo, capture_output=True, text=True, check=True,
)
target = Path(json.loads(result.stdout)["target_directory"])
return target / "xtensa-esp32s3-espidf" / "release" / "esp32"
def export_app(repo: Path, firmware: Path, elf: Path, allow_stale: bool) -> None:
"""从选中的 ELF 导出 App,并同步同次构建的配套镜像。"""
app = firmware / "esp32s3-app.bin"
if not elf.is_file():
if allow_stale and app.is_file():
print(f"警告:缺少 {elf};继续打包现有镜像", file=sys.stderr)
return
raise SystemExit(f"固件 ELF 不存在:{elf}。请先构建,或用 --elf 指定本次构建的 ELF。")
companions = [elf.parent / name for name in ("bootloader.bin", "partition-table.bin")]
for source in companions:
if not source.is_file():
raise SystemExit(f"缺少同次构建的配套镜像:{source}")
espflash = repo / "target" / "tools" / "espflash" / "espflash.exe"
if not espflash.is_file():
found = shutil.which("espflash")
espflash = Path(found) if found else Path("")
if not espflash.is_file():
message = "找不到 espflash,无法从最新 ELF 导出 App 镜像"
if allow_stale:
print(f"警告:{message};继续打包现有镜像", file=sys.stderr)
return
raise SystemExit(f"{message}。请安装 espflash,或加 --allow-stale 打包现有镜像。")
firmware.mkdir(parents=True, exist_ok=True)
print(f"导出 App:{elf} -> {app}")
result = subprocess.run(
[
str(espflash),
"save-image",
"--chip",
"esp32s3",
"--format",
"esp-idf",
"--flash-size",
"16mb",
"--flash-mode",
"dio",
"--flash-freq",
"40mhz",
"--skip-update-check",
str(elf),
str(app),
]
)
if result.returncode != 0:
raise SystemExit(f"espflash save-image 失败(exit {result.returncode})")
for source in companions:
destination = firmware / source.name
if source.resolve() != destination.resolve():
shutil.copy2(source, destination)
# Do not attach a previous build's hardware/performance claims to new images.
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
dirty = subprocess.check_output(
["git", "status", "--porcelain", "--untracked-files=normal"], cwd=repo, text=True,
).strip()
build = {
"packaged_at": datetime.fromtimestamp(elf.stat().st_mtime, timezone.utc).isoformat(),
"source_revision": revision,
"includes_local_changes": bool(dirty),
"target": "xtensa-esp32s3-espidf",
"app_sha256": hashlib.sha256(app.read_bytes()).hexdigest(),
"verification": {"hardware_flashed_and_tested": False},
}
(firmware / "BUILD-INFO.json").write_text(
json.dumps(build, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
)
def main() -> int:
parser = argparse.ArgumentParser(description="生成官方 .zip 固件包")
parser.add_argument("--firmware", default="target/firmware", help="固件产物目录")
parser.add_argument("--elf", default="", help="本次构建的 ELF;默认通过 cargo metadata 查找 release 产物")
parser.add_argument("--name", default="", help="传递给 make_bundle.py 的名称")
parser.add_argument("--version", default="", help="传递给 make_bundle.py 的版本;默认读取 version.json")
parser.add_argument("--skip-manifest", action="store_true", help="不重新生成 bundle.json")
parser.add_argument("--allow-stale", action="store_true", help="缺少 ELF 或 espflash 时,允许打包目录中的现有镜像")
parser.add_argument("--output", default="", help="输出 zip 路径;默认 target/firmware/camfw-*.zip")
args = parser.parse_args()
repo = Path(__file__).resolve().parent.parent
if not args.version and (repo / "version.json").is_file():
version = json.loads((repo / "version.json").read_text(encoding="utf-8"))
args.version = f"v{version['version']}+{version['build']}"
firmware = Path(args.firmware)
if not firmware.is_absolute():
firmware = (Path.cwd() / firmware).resolve()
try:
export_app(repo, firmware, resolve_elf(repo, args.elf), args.allow_stale)
except (SystemExit, OSError, subprocess.CalledProcessError, ValueError) as error:
print(error, file=sys.stderr)
return 1
if not args.skip_manifest:
make_bundle = repo / "scripts" / "make_bundle.py"
command = [sys.executable, str(make_bundle), "--firmware", str(firmware)]
if args.name:
command += ["--name", args.name]
if args.version:
command += ["--version", args.version]
result = subprocess.run(command, text=True)
if result.returncode != 0:
print("make_bundle.py 失败,未生成 zip", file=sys.stderr)
return result.returncode
bundle_path = firmware / "bundle.json"
if not bundle_path.is_file():
print(f"缺少 {bundle_path}", file=sys.stderr)
return 1
bundle = json.loads(bundle_path.read_text(encoding="utf-8"))
build = {}
build_info_path = firmware / "BUILD-INFO.json"
if build_info_path.is_file():
try:
build = json.loads(build_info_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
build = {}
app = firmware / bundle["app"]
partition_table = firmware / bundle["partitionTable"]
if not app.is_file() or not partition_table.is_file():
print("bundle.json 里的 App/分区表文件不存在", file=sys.stderr)
return 1
if partition_table.stat().st_size != 0xC00:
print("partition-table.bin 必须是 0xC00 字节", file=sys.stderr)
return 1
bootloader_name = bundle.get("bootloader")
bootloader = firmware / bootloader_name if bootloader_name else None
if bootloader is not None and not bootloader.is_file():
print(f"bundle.json 声明的 bootloader 不存在:{bootloader}", file=sys.stderr)
return 1
epoch = parse_epoch(build)
stamp = time.gmtime(epoch)
date_time = (
stamp.tm_year,
stamp.tm_mon,
stamp.tm_mday,
stamp.tm_hour,
stamp.tm_min,
stamp.tm_sec,
)
date_text = f"{stamp.tm_year:04d}{stamp.tm_mon:02d}{stamp.tm_mday:02d}"
variant = sanitize(str(build.get("build_variant", "firmware")))
revision = sanitize(str(build.get("source_revision", ""))[:7])
chip = sanitize(str(bundle.get("chip", "esp32s3")))
# 文件名优先用**传进来的版本**(`--version`,来自仓库根的 version.json)。
#
# 原来只用 BUILD-INFO.json 里的变体名 + 提交号 + 日期,那是另一套编号:固件包叫
# `...-4794bed-20261018.zip`,而客户端里显示的是 `v0.2.0+34`,两个数对不上,
# 现场拿到一个 zip 没法说清它是不是界面上那一版。给了版本就用版本,没给才退回旧格式。
if args.version:
filename = f"camfw-{chip}-{sanitize(args.version)}.zip"
else:
suffix = f"-{revision}" if revision else ""
filename = f"camfw-{chip}-{variant}{suffix}-{date_text}.zip"
output = Path(args.output) if args.output else firmware / filename
if not output.is_absolute():
output = (Path.cwd() / output).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
entries: list[tuple[str, bytes]] = [
("bundle.json", bundle_path.read_bytes()),
(app.name, app.read_bytes()),
(partition_table.name, partition_table.read_bytes()),
]
if bootloader is not None:
entries.append((bootloader.name, bootloader.read_bytes()))
if build_info_path.is_file():
entries.append(("BUILD-INFO.json", build_info_path.read_bytes()))
with zipfile.ZipFile(
output,
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
for name, data in entries:
info = zipfile.ZipInfo(filename=name, date_time=date_time)
info.compress_type = zipfile.ZIP_DEFLATED
info.create_system = 3
info.external_attr = 0o100644 << 16
archive.writestr(info, data)
digest = hashlib.sha256(output.read_bytes()).hexdigest()
sidecar = output.with_name(output.name + ".sha256")
sidecar.write_text(f"{digest} {output.name}\n", encoding="utf-8")
print(f"已生成 {output}")
print(f" {output.stat().st_size} 字节 · {len(entries)} 项 · SHA256 {digest}")
print(f" {sidecar}")
return 0
if __name__ == "__main__":
raise SystemExit(main())