Files
xiaozhi-esp32/scripts/release.py
2026-04-26 21:35:04 +08:00

457 lines
17 KiB
Python

import sys
import os
import json
import zipfile
import argparse
import re
from pathlib import Path
from typing import Optional
# Switch to project root directory
os.chdir(Path(__file__).resolve().parent.parent)
################################################################################
# Common utility functions
################################################################################
def get_board_type_from_compile_commands() -> Optional[str]:
"""Parse the current compiled BOARD_TYPE from build/compile_commands.json"""
compile_file = Path("build/compile_commands.json")
if not compile_file.exists():
return None
with compile_file.open(encoding='utf-8') as f:
data = json.load(f)
for item in data:
if not item["file"].endswith("main.cc"):
continue
cmd = item["command"]
if "-DBOARD_TYPE=\\\"" in cmd:
return cmd.split("-DBOARD_TYPE=\\\"")[1].split("\\\"")[0].strip()
return None
def get_project_version() -> Optional[str]:
"""Read set(PROJECT_VER "x.y.z") from root CMakeLists.txt"""
with Path("CMakeLists.txt").open(encoding='utf-8') as f:
for line in f:
if line.startswith("set(PROJECT_VER"):
return line.split("\"")[1]
return None
def merge_bin() -> None:
if os.system("idf.py merge-bin") != 0:
print("merge-bin failed", file=sys.stderr)
sys.exit(1)
def zip_bin(name: str, version: str) -> None:
"""Zip build/merged-binary.bin to releases/v{version}_{name}.zip"""
out_dir = Path("releases")
out_dir.mkdir(exist_ok=True)
output_path = out_dir / f"v{version}_{name}.zip"
if output_path.exists():
output_path.unlink()
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
zipf.write("build/merged-binary.bin", arcname="merged-binary.bin")
print(f"zip bin to {output_path} done")
def _get_manufacturer(cfg: dict) -> Optional[str]:
"""Read manufacturer from config.json"""
m = cfg.get("manufacturer")
if isinstance(m, str) and m.strip():
return m.strip()
return None
################################################################################
# board / variant related functions
################################################################################
_BOARDS_DIR = Path("main/boards")
def _collect_variants(config_filename: str = "config.json") -> list[dict[str, str]]:
"""Traverse all boards under main/boards, collect variant information.
Return example:
[{"board": "bread-compact-ml307", "name": "bread-compact-ml307", "full_name": "bread-compact-ml307"}, ...]
[{"board": "waveshare/esp32-p4-nano", "name": "esp32-p4-nano-10.1-a", "full_name": "waveshare-esp32-p4-nano-10.1-a"}, ...]
"""
variants: list[dict[str, str]] = []
errors: list[str] = []
for cfg_path in _BOARDS_DIR.rglob(config_filename):
board_dir = cfg_path.parent
if board_dir.name == "common":
continue
board = board_dir.relative_to(_BOARDS_DIR).as_posix()
try:
with cfg_path.open(encoding='utf-8') as f:
cfg = json.load(f)
manufacturer = _get_manufacturer(cfg)
# Check manufacturer consistency with directory structure
if "/" in board:
# Board is in a subdirectory (e.g., waveshare/esp32-p4-nano)
expected_manufacturer = board.split("/")[0]
if not manufacturer:
errors.append(
f"{cfg_path}: Board is in '{expected_manufacturer}/' subdirectory, "
f"but config.json is missing \"manufacturer\": \"{expected_manufacturer}\""
)
elif manufacturer != expected_manufacturer:
errors.append(
f"{cfg_path}: manufacturer mismatch, "
f"directory is '{expected_manufacturer}/' but config.json has \"{manufacturer}\""
)
else:
# Board is directly under boards/ directory
if manufacturer:
errors.append(
f"{cfg_path}: Board is not in a manufacturer subdirectory, "
f"but config.json defines manufacturer \"{manufacturer}\", "
f"please move board to main/boards/{manufacturer}/{board}/"
)
for build in cfg.get("builds", []):
name = build["name"]
full_name = f"{manufacturer}-{name}" if manufacturer else name
variants.append({
"board": board,
"name": name,
"full_name": full_name
})
except Exception as e:
print(f"[ERROR] Failed to parse {cfg_path}: {e}", file=sys.stderr)
# Report all errors at once
if errors:
print("\n[ERROR] Found manufacturer configuration issues:", file=sys.stderr)
for err in errors:
print(f" - {err}", file=sys.stderr)
print(file=sys.stderr)
sys.exit(1)
return variants
def _find_board_config_candidates(board_type: str) -> list[str]:
"""Find all CONFIG_BOARD_TYPE_xxx candidates for the given board_type."""
board_leaf = board_type.split("/")[-1]
pattern = f'set(BOARD_TYPE "{board_leaf}")'
cmake_file = Path("main/CMakeLists.txt")
lines = cmake_file.read_text(encoding="utf-8").splitlines()
candidates: list[str] = []
for idx, line in enumerate(lines):
if pattern in line:
# Found the BOARD_TYPE line, search backwards for the nearest config guard
for back_idx in range(idx - 1, -1, -1):
back_line = lines[back_idx]
if "if(CONFIG_BOARD_TYPE_" in back_line:
candidates.append(back_line.strip().split("if(")[1].split(")")[0])
break
return candidates
def _extract_board_config_from_sdkconfig_append(sdkconfig_append: list[str]) -> Optional[str]:
"""Extract explicit CONFIG_BOARD_TYPE_xxx=y from sdkconfig_append, if present."""
pattern = re.compile(r"^(CONFIG_BOARD_TYPE_[A-Z0-9_]+)=y$")
matches = []
for item in sdkconfig_append:
m = pattern.match(item.strip())
if m:
matches.append(m.group(1))
if not matches:
return None
uniq = list(dict.fromkeys(matches))
if len(uniq) > 1:
raise ValueError(f"Multiple board type configs found in sdkconfig_append: {uniq}")
return uniq[0]
def _symbol_supports_target(symbol: str, target: str) -> bool:
"""Check whether Kconfig symbol depends on given target (e.g. esp32c5)."""
kconfig_file = Path("main/Kconfig.projbuild")
if not kconfig_file.exists():
return False
target_flag = f"IDF_TARGET_{target.upper()}"
lines = kconfig_file.read_text(encoding="utf-8").splitlines()
in_symbol = False
for line in lines:
stripped = line.strip()
if stripped.startswith("config "):
curr_symbol = stripped.split("config ", 1)[1].strip()
in_symbol = curr_symbol == symbol
continue
if in_symbol and stripped.startswith(("config ", "choice ", "endchoice", "menu ", "endmenu")):
break
if in_symbol and "depends on" in stripped and target_flag in stripped:
return True
return False
def _resolve_board_config(board_type: str, target: str, sdkconfig_append: list[str]) -> str:
"""Resolve CONFIG_BOARD_TYPE_xxx for current board build."""
explicit = _extract_board_config_from_sdkconfig_append(sdkconfig_append)
if explicit:
return explicit
candidates = _find_board_config_candidates(board_type)
if not candidates:
raise ValueError(f"Cannot find board config symbol for {board_type}")
if len(candidates) == 1:
return candidates[0]
by_target = [c for c in candidates if _symbol_supports_target(c, target)]
if len(by_target) == 1:
return by_target[0]
if len(by_target) > 1:
selected = by_target[0]
print(
f"[WARN] Ambiguous board config for {board_type} (target={target}), "
f"target-matched candidates={by_target}, selecting first: {selected}",
file=sys.stderr,
)
return selected
target_u = target.upper()
target_short = target_u.replace("ESP32", "")
by_name = [
c for c in candidates
if target_u in c or f"_{target_short}" in c
]
if len(by_name) == 1:
return by_name[0]
if len(by_name) > 1:
selected = by_name[0]
print(
f"[WARN] Ambiguous board config for {board_type} (target={target}), "
f"name-matched candidates={by_name}, selecting first: {selected}",
file=sys.stderr,
)
return selected
selected = candidates[0]
print(
f"[WARN] Ambiguous board config for {board_type} (target={target}), "
f"candidates={candidates}, selecting first: {selected}",
file=sys.stderr,
)
return selected
# Kconfig "select" entries are not automatically applied when we simply append
# sdkconfig lines from config.json, so add the required dependencies here to
# mimic menuconfig behaviour.
_AUTO_SELECT_RULES: dict[str, list[str]] = {
"CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING": [
"CONFIG_BT_ENABLED=y",
"CONFIG_BT_BLUEDROID_ENABLED=y",
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y",
"CONFIG_BT_BLE_50_FEATURES_SUPPORTED=n",
"CONFIG_BT_BLE_BLUFI_ENABLE=y",
"CONFIG_MBEDTLS_DHM_C=y",
],
}
def _apply_auto_selects(sdkconfig_append: list[str]) -> list[str]:
"""Apply hardcoded auto-select rules to sdkconfig_append."""
items: list[str] = []
existing_keys: set[str] = set()
def _append_if_missing(entry: str) -> None:
key = entry.split("=", 1)[0]
if key not in existing_keys:
items.append(entry)
existing_keys.add(key)
# Preserve original order while tracking keys
for entry in sdkconfig_append:
_append_if_missing(entry)
# Apply auto-select rules
for key, deps in _AUTO_SELECT_RULES.items():
for entry in sdkconfig_append:
name, _, value = entry.partition("=")
if name == key and value.lower().startswith("y"):
for dep in deps:
_append_if_missing(dep)
break
return items
################################################################################
# Check board_type in CMakeLists
################################################################################
def _board_type_exists(board_type: str) -> bool:
cmake_file = Path("main/CMakeLists.txt").read_text(encoding="utf-8")
board_leaf = board_type.split("/")[-1]
pattern = f'set(BOARD_TYPE "{board_leaf}")'
return pattern in cmake_file
################################################################################
# Compile implementation
################################################################################
def release(board_type: str, config_filename: str = "config.json", *, filter_name: Optional[str] = None) -> None:
"""Compile and package all/specified variants of the specified board_type
Args:
board_type: directory name under main/boards
config_filename: config.json name (default: config.json)
filter_name: if specified, only compile the build["name"] that matches
"""
cfg_path = _BOARDS_DIR / Path(board_type) / config_filename
if not cfg_path.exists():
print(f"[WARN] {cfg_path} does not exist, skipping {board_type}")
return
project_version = get_project_version()
print(f"Project Version: {project_version} ({cfg_path})")
with cfg_path.open(encoding='utf-8') as f:
cfg = json.load(f)
target = cfg["target"]
manufacturer = _get_manufacturer(cfg)
builds = cfg.get("builds", [])
if filter_name:
builds = [b for b in builds if b["name"] == filter_name]
if not builds:
print(f"[ERROR] Variant {filter_name} not found in {board_type}'s {config_filename}", file=sys.stderr)
sys.exit(1)
for build in builds:
name = build["name"]
board_leaf = board_type.split("/")[-1]
if board_leaf not in name:
raise ValueError(f"build.name {name} must contain {board_leaf}")
final_name = f"{manufacturer}-{name}" if manufacturer else name
output_path = Path("releases") / f"v{project_version}_{final_name}.zip"
if output_path.exists():
print(f"Skipping {final_name} because {output_path} already exists")
continue
# Process sdkconfig_append
build_sdkconfig_append = build.get("sdkconfig_append", [])
explicit_board_cfg = _extract_board_config_from_sdkconfig_append(build_sdkconfig_append)
if explicit_board_cfg:
print(
f"[INFO] Board config explicitly set in config.json: {explicit_board_cfg}, "
"skip auto-select.",
)
sdkconfig_append = list(build_sdkconfig_append)
else:
board_type_config = _resolve_board_config(board_type, target, build_sdkconfig_append)
sdkconfig_append = [f"{board_type_config}=y"]
sdkconfig_append.extend(build_sdkconfig_append)
sdkconfig_append = _apply_auto_selects(sdkconfig_append)
print("-" * 80)
print(f"name: {final_name}")
print(f"target: {target}")
if manufacturer:
print(f"manufacturer: {manufacturer}")
for item in sdkconfig_append:
print(f"sdkconfig_append: {item}")
os.environ.pop("IDF_TARGET", None)
# Call set-target
if os.system(f"idf.py set-target {target}") != 0:
print("set-target failed", file=sys.stderr)
sys.exit(1)
# Append sdkconfig
with Path("sdkconfig").open("a", encoding='utf-8') as f:
f.write("\n")
f.write("# Append by release.py\n")
for append in sdkconfig_append:
f.write(f"{append}\n")
# Build with macro BOARD_NAME defined to name
if os.system(f"idf.py -DBOARD_NAME={name} -DBOARD_TYPE={board_type} build") != 0:
print("build failed")
sys.exit(1)
# merge-bin
merge_bin()
# Zip
zip_bin(final_name, project_version)
################################################################################
# CLI entry
################################################################################
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("board", nargs="?", default=None, help="Board type or 'all'")
parser.add_argument("-c", "--config", default="config.json", help="Config filename (default: config.json)")
parser.add_argument("--list-boards", action="store_true", help="List all supported boards and variants")
parser.add_argument("--json", action="store_true", help="Output in JSON format (use with --list-boards)")
parser.add_argument("--name", help="Variant name to compile (original name without manufacturer prefix)")
args = parser.parse_args()
# List mode
if args.list_boards:
variants = _collect_variants(config_filename=args.config)
if args.json:
print(json.dumps(variants))
else:
for v in variants:
print(f"{v['board']}: {v['name']}")
sys.exit(0)
# Current directory firmware packaging mode
if args.board is None:
merge_bin()
curr_board_type = get_board_type_from_compile_commands()
if curr_board_type is None:
print("Failed to parse board_type from compile_commands.json", file=sys.stderr)
sys.exit(1)
project_ver = get_project_version()
zip_bin(curr_board_type, project_ver)
sys.exit(0)
# Compile mode
board_type_input: str = args.board
name_filter: Optional[str] = args.name
# Check board_type in CMakeLists
if board_type_input != "all" and not _board_type_exists(board_type_input):
print(f"[ERROR] board_type {board_type_input} not found in main/CMakeLists.txt", file=sys.stderr)
sys.exit(1)
variants_all = _collect_variants(config_filename=args.config)
# Filter board_type list
target_board_types: set[str]
if board_type_input == "all":
target_board_types = {v["board"] for v in variants_all}
else:
target_board_types = {board_type_input}
for bt in sorted(target_board_types):
if not _board_type_exists(bt):
print(f"[ERROR] board_type {bt} not found in main/CMakeLists.txt", file=sys.stderr)
sys.exit(1)
cfg_path = _BOARDS_DIR / bt / args.config
if bt == board_type_input and not cfg_path.exists():
print(f"Board {bt} has no {args.config} config file, skipping")
sys.exit(0)
release(bt, config_filename=args.config, filter_name=name_filter if bt == board_type_input else None)