Update build workflow and firmware builder for improved board support
- Updated Docker image in GitHub Actions workflow from `espressif/idf:v6.0.1` to `espressif/idf:release-v6.1`. - Removed obsolete build configurations for ESP32-S31 and BluFi options. - Enhanced `firmware_builder.py` to emit build stage markers and handle board configurations more effectively. - Updated documentation to reflect changes in build process and configuration handling. - Added tests to verify board display names and configuration resolution logic.
This commit is contained in:
+170
-31
@@ -38,6 +38,13 @@ _ESP_SR_KCONFIG = Path(
|
||||
)
|
||||
|
||||
|
||||
def _emit_build_stage(stage: str) -> None:
|
||||
"""Emit a machine-readable stage marker for cloud build runners."""
|
||||
enabled = os.environ.get("XIAOZHI_BUILD_STAGES", "").strip().casefold()
|
||||
if enabled in {"1", "true", "yes", "on"}:
|
||||
print(f"XIAOZHI_STAGE {stage}", flush=True)
|
||||
|
||||
|
||||
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:
|
||||
@@ -422,6 +429,36 @@ def _get_builds_for_idf(cfg: dict, idf_version: tuple[int, int, int]) -> list[di
|
||||
return builds
|
||||
|
||||
|
||||
def _get_board_display_name(
|
||||
config_symbol: str,
|
||||
kconfig_path: Path = Path("main/Kconfig.projbuild"),
|
||||
) -> str:
|
||||
"""Return the user-facing bool prompt for a CONFIG_BOARD_TYPE_* symbol."""
|
||||
symbol = config_symbol.removeprefix("CONFIG_")
|
||||
if not kconfig_path.exists():
|
||||
raise ValueError(f"Board Kconfig file not found: {kconfig_path}")
|
||||
|
||||
in_symbol = False
|
||||
for line in kconfig_path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("config "):
|
||||
if in_symbol:
|
||||
break
|
||||
in_symbol = stripped.split("config ", 1)[1].strip() == symbol
|
||||
continue
|
||||
if not in_symbol:
|
||||
continue
|
||||
prompt = re.match(r'^bool\s+"([^"]+)"', stripped)
|
||||
if prompt:
|
||||
return prompt.group(1)
|
||||
if stripped.startswith(("choice ", "endchoice", "menu ", "endmenu")):
|
||||
break
|
||||
|
||||
raise ValueError(
|
||||
f'Kconfig bool prompt not found for {config_symbol} in {kconfig_path}'
|
||||
)
|
||||
|
||||
|
||||
def _collect_variants(
|
||||
config_filename: str = "config.json",
|
||||
idf_version: tuple[int, int, int] = (6, 0, 0),
|
||||
@@ -450,6 +487,9 @@ def _collect_variants(
|
||||
|
||||
manufacturer = _get_manufacturer(cfg)
|
||||
reported_type = _get_reported_type(cfg)
|
||||
target = cfg.get("target")
|
||||
if not isinstance(target, str) or not target:
|
||||
raise ValueError(f"{cfg_path}: missing non-empty target")
|
||||
|
||||
previous_board = type_owners.get(reported_type)
|
||||
if previous_board is not None and previous_board != board:
|
||||
@@ -508,9 +548,11 @@ def _collect_variants(
|
||||
identity_owners[identity] = str(cfg_path)
|
||||
|
||||
variants.append({
|
||||
"board": board,
|
||||
"board": board,
|
||||
"name": name,
|
||||
"full_name": full_name
|
||||
"full_name": full_name,
|
||||
"type": reported_type,
|
||||
"target": target,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -531,6 +573,33 @@ def _collect_variants(
|
||||
details = "\n".join(f" - {error}" for error in errors)
|
||||
raise ValueError(f"Invalid board configuration:\n{details}")
|
||||
|
||||
# Enrich only after the compatibility identities above are validated, so
|
||||
# duplicate/malformed config errors remain the primary actionable failure.
|
||||
for variant in variants:
|
||||
cfg_path = _BOARDS_DIR / variant["board"] / config_filename
|
||||
with cfg_path.open(encoding="utf-8") as file:
|
||||
cfg = json.load(file)
|
||||
build = next(
|
||||
item for item in _get_builds_for_idf(cfg, idf_version)
|
||||
if _get_reported_name(item) == variant["name"]
|
||||
)
|
||||
sdkconfig_append = build.get("sdkconfig_append", [])
|
||||
if not isinstance(sdkconfig_append, list) or not all(
|
||||
isinstance(item, str) for item in sdkconfig_append
|
||||
):
|
||||
raise ValueError(
|
||||
f'{cfg_path}: build {variant["name"]!r} '
|
||||
'sdkconfig_append must be a string list'
|
||||
)
|
||||
config_symbol = _resolve_board_config(
|
||||
variant["board"],
|
||||
variant["target"],
|
||||
sdkconfig_append,
|
||||
variant_name=variant["name"],
|
||||
)
|
||||
variant["config"] = config_symbol
|
||||
variant["display_name"] = _get_board_display_name(config_symbol)
|
||||
|
||||
return sorted(variants, key=lambda variant: (variant["board"], variant["name"]))
|
||||
|
||||
|
||||
@@ -634,6 +703,21 @@ def _extract_board_config_from_sdkconfig_append(sdkconfig_append: list[str]) ->
|
||||
return uniq[0]
|
||||
|
||||
|
||||
def _board_config_symbol_exists(
|
||||
symbol: str,
|
||||
kconfig_path: Path = Path("main/Kconfig.projbuild"),
|
||||
) -> bool:
|
||||
"""Return whether a CONFIG_BOARD_TYPE_* symbol exists in project Kconfig."""
|
||||
if not kconfig_path.exists():
|
||||
return False
|
||||
name = symbol.removeprefix("CONFIG_")
|
||||
return bool(re.search(
|
||||
rf"^\s*config\s+{re.escape(name)}\s*$",
|
||||
kconfig_path.read_text(encoding="utf-8"),
|
||||
re.MULTILINE,
|
||||
))
|
||||
|
||||
|
||||
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")
|
||||
@@ -641,6 +725,7 @@ def _symbol_supports_target(symbol: str, target: str) -> bool:
|
||||
return False
|
||||
|
||||
target_flag = f"IDF_TARGET_{target.upper()}"
|
||||
symbol = symbol.removeprefix("CONFIG_")
|
||||
lines = kconfig_file.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
in_symbol = False
|
||||
@@ -657,11 +742,23 @@ def _symbol_supports_target(symbol: str, target: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_board_config(board_type: str, target: str, sdkconfig_append: list[str]) -> str:
|
||||
def _resolve_board_config(
|
||||
board_type: str,
|
||||
target: str,
|
||||
sdkconfig_append: list[str],
|
||||
*,
|
||||
variant_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Resolve CONFIG_BOARD_TYPE_xxx for current board build."""
|
||||
explicit = _extract_board_config_from_sdkconfig_append(sdkconfig_append)
|
||||
if explicit:
|
||||
if explicit and _board_config_symbol_exists(explicit):
|
||||
return explicit
|
||||
if explicit:
|
||||
print(
|
||||
f"[WARN] Explicit board config {explicit} does not exist in Kconfig; "
|
||||
f"resolving it from BOARD_DIR={board_type!r} instead.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
candidates = _find_board_config_candidates(board_type)
|
||||
if not candidates:
|
||||
@@ -669,6 +766,16 @@ def _resolve_board_config(board_type: str, target: str, sdkconfig_append: list[s
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
|
||||
if variant_name:
|
||||
expected = "CONFIG_BOARD_TYPE_" + re.sub(
|
||||
r"[^A-Z0-9]+",
|
||||
"_",
|
||||
variant_name.upper(),
|
||||
).strip("_")
|
||||
by_variant = [candidate for candidate in candidates if candidate == expected]
|
||||
if len(by_variant) == 1:
|
||||
return by_variant[0]
|
||||
|
||||
by_target = [c for c in candidates if _symbol_supports_target(c, target)]
|
||||
if len(by_target) == 1:
|
||||
return by_target[0]
|
||||
@@ -808,40 +915,62 @@ def _configured_target() -> Optional[str]:
|
||||
return cache_target or sdkconfig_target
|
||||
|
||||
|
||||
def _ensure_target(target: str, preview: bool) -> bool:
|
||||
"""Switch targets only when needed; return whether set-target ran."""
|
||||
current_target = _configured_target()
|
||||
if current_target == target:
|
||||
print(f"[INFO] Reusing target {target}; skipping idf.py set-target.")
|
||||
def _sync_vscode_target(
|
||||
target: str,
|
||||
settings_path: Path = Path(".vscode/settings.json"),
|
||||
) -> bool:
|
||||
"""Keep an existing VS Code ESP-IDF target setting in sync."""
|
||||
if not settings_path.exists():
|
||||
return False
|
||||
|
||||
if current_target:
|
||||
print(f"[INFO] Switching target from {current_target} to {target}.")
|
||||
else:
|
||||
print(f"[INFO] Configuring target {target}.")
|
||||
_run_idf("set-target", target, preview=preview)
|
||||
content = settings_path.read_text(encoding="utf-8")
|
||||
pattern = re.compile(
|
||||
r'(?P<prefix>"IDF_TARGET"\s*:\s*")'
|
||||
r'(?:\\.|[^"\\])*'
|
||||
r'(?P<suffix>")'
|
||||
)
|
||||
updated, matches = pattern.subn(
|
||||
lambda match: f'{match.group("prefix")}{target}{match.group("suffix")}',
|
||||
content,
|
||||
)
|
||||
if matches == 0 or updated == content:
|
||||
return False
|
||||
|
||||
settings_path.write_text(updated, encoding="utf-8")
|
||||
print(f"[INFO] Updated {settings_path} IDF_TARGET to {target}.")
|
||||
return True
|
||||
|
||||
|
||||
def _regenerate_sdkconfig(
|
||||
def _prepare_target(target: str, preview: bool) -> None:
|
||||
"""Clean only when an existing CMake build uses another target."""
|
||||
cache_target = _target_from_cmake_cache()
|
||||
current_target = _configured_target()
|
||||
if current_target == target:
|
||||
print(f"[INFO] Reusing target {target}.")
|
||||
return
|
||||
|
||||
if cache_target and cache_target != target:
|
||||
print(f"[INFO] Switching target from {cache_target} to {target}.")
|
||||
_run_idf("fullclean", preview=preview)
|
||||
elif current_target:
|
||||
print(f"[INFO] Configuring target {target} (was {current_target}).")
|
||||
else:
|
||||
print(f"[INFO] Configuring target {target}.")
|
||||
|
||||
|
||||
def _configure_build(
|
||||
target: str,
|
||||
sdkconfig_append: list[str],
|
||||
board_name: str,
|
||||
preview: bool,
|
||||
*,
|
||||
target_changed: bool = False,
|
||||
) -> None:
|
||||
"""Regenerate sdkconfig from project defaults plus the selected variant."""
|
||||
"""Configure target, board identity and sdkconfig defaults in one CMake run."""
|
||||
sdkconfig = Path("sdkconfig")
|
||||
sdkconfig_old = Path("sdkconfig.old")
|
||||
if sdkconfig.exists():
|
||||
if target_changed:
|
||||
# set-target has already saved the user's previous config as
|
||||
# sdkconfig.old. Discard only the fresh default config it created.
|
||||
sdkconfig.unlink()
|
||||
else:
|
||||
if sdkconfig_old.exists():
|
||||
sdkconfig_old.unlink()
|
||||
sdkconfig.replace(sdkconfig_old)
|
||||
if sdkconfig_old.exists():
|
||||
sdkconfig_old.unlink()
|
||||
sdkconfig.replace(sdkconfig_old)
|
||||
|
||||
fragment = Path("build/xiaozhi-build.sdkconfig.defaults")
|
||||
fragment.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -859,9 +988,11 @@ def _regenerate_sdkconfig(
|
||||
_run_idf(
|
||||
f"-DIDF_TARGET={target}",
|
||||
f"-DSDKCONFIG_DEFAULTS={';'.join(defaults)}",
|
||||
f"-DBOARD_NAME={board_name}",
|
||||
"reconfigure",
|
||||
preview=preview,
|
||||
)
|
||||
_sync_vscode_target(target)
|
||||
|
||||
|
||||
def _validate_configured_symbols(symbols: list[str], option_name: str) -> None:
|
||||
@@ -950,7 +1081,12 @@ def build_board(
|
||||
)
|
||||
sdkconfig_append = list(build_sdkconfig_append)
|
||||
else:
|
||||
board_type_config = _resolve_board_config(board_type, target, build_sdkconfig_append)
|
||||
board_type_config = _resolve_board_config(
|
||||
board_type,
|
||||
target,
|
||||
build_sdkconfig_append,
|
||||
variant_name=name,
|
||||
)
|
||||
sdkconfig_append = [f"{board_type_config}=y"]
|
||||
sdkconfig_append.extend(build_sdkconfig_append)
|
||||
|
||||
@@ -994,21 +1130,24 @@ def build_board(
|
||||
for item in sdkconfig_append:
|
||||
print(f"sdkconfig_append: {item}")
|
||||
|
||||
_emit_build_stage("dependencies_resolving")
|
||||
os.environ.pop("IDF_TARGET", None)
|
||||
target_changed = _ensure_target(target, preview)
|
||||
_regenerate_sdkconfig(
|
||||
_prepare_target(target, preview)
|
||||
_configure_build(
|
||||
target,
|
||||
sdkconfig_append,
|
||||
name,
|
||||
preview,
|
||||
target_changed=target_changed,
|
||||
)
|
||||
for symbols, option_name in validation_symbols:
|
||||
_validate_configured_symbols(symbols, option_name)
|
||||
|
||||
# build.name is the compatibility-sensitive OTA-reported board identity.
|
||||
_run_idf(f"-DBOARD_NAME={name}", "build", preview=preview)
|
||||
_emit_build_stage("compiling")
|
||||
_run_idf("build", preview=preview)
|
||||
|
||||
# merge-bin
|
||||
_emit_build_stage("packaging")
|
||||
merge_bin(preview)
|
||||
|
||||
if create_zip:
|
||||
|
||||
+98
-15
@@ -65,6 +65,17 @@ class VersionTests(unittest.TestCase):
|
||||
self.assertIn("espressif-esp32-s31-function-coreboard-1", idf61_names)
|
||||
self.assertIn("rymcu-bigsmart", idf61_names)
|
||||
self.assertNotIn("rymcu-rymcu-bigsmart", idf61_names)
|
||||
atk = next(
|
||||
variant for variant in idf61
|
||||
if variant["board"] == "alientek/atk-dnesp32s3"
|
||||
)
|
||||
self.assertEqual(atk["type"], "atk-dnesp32s3")
|
||||
self.assertEqual(atk["target"], "esp32s3")
|
||||
self.assertEqual(atk["config"], "CONFIG_BOARD_TYPE_ATK_DNESP32S3")
|
||||
self.assertEqual(
|
||||
atk["display_name"],
|
||||
"Alientek ATK-DNESP32S3 Development Board (正点原子)",
|
||||
)
|
||||
self.assertEqual(
|
||||
build._get_release_full_name(
|
||||
"espressif",
|
||||
@@ -318,6 +329,26 @@ class BoardSelectionTests(unittest.TestCase):
|
||||
"CONFIG_BOARD_TYPE_ESP32_S3_BOX_3",
|
||||
)
|
||||
|
||||
def test_board_display_name_comes_from_kconfig_prompt(self):
|
||||
self.assertEqual(
|
||||
build._get_board_display_name(
|
||||
"CONFIG_BOARD_TYPE_ATK_DNESP32S3"
|
||||
),
|
||||
"Alientek ATK-DNESP32S3 Development Board (正点原子)",
|
||||
)
|
||||
|
||||
def test_variant_name_disambiguates_shared_board_directory(self):
|
||||
board = "lilygo/t-cameraplus-s3"
|
||||
self.assertEqual(
|
||||
build._resolve_board_config(
|
||||
board,
|
||||
"esp32s3",
|
||||
[],
|
||||
variant_name="lilygo-t-cameraplus-s3-v1.2",
|
||||
),
|
||||
"CONFIG_BOARD_TYPE_LILYGO_T_CAMERAPLUS_S3_V1_2",
|
||||
)
|
||||
|
||||
def test_m5stack_directory_can_omit_manufacturer_prefix(self):
|
||||
board = "m5stack/cardputer-adv"
|
||||
self.assertTrue(build._board_type_exists(board))
|
||||
@@ -601,6 +632,27 @@ class InvalidConfigTests(unittest.TestCase):
|
||||
|
||||
|
||||
class PreviewTargetTests(unittest.TestCase):
|
||||
def test_stage_marker_is_only_emitted_when_enabled(self):
|
||||
output = io.StringIO()
|
||||
with (
|
||||
mock.patch.dict(os.environ, {}, clear=True),
|
||||
contextlib.redirect_stdout(output),
|
||||
):
|
||||
build._emit_build_stage("compiling")
|
||||
self.assertEqual(output.getvalue(), "")
|
||||
|
||||
output = io.StringIO()
|
||||
with (
|
||||
mock.patch.dict(
|
||||
os.environ,
|
||||
{"XIAOZHI_BUILD_STAGES": "true"},
|
||||
clear=True,
|
||||
),
|
||||
contextlib.redirect_stdout(output),
|
||||
):
|
||||
build._emit_build_stage("compiling")
|
||||
self.assertEqual(output.getvalue(), "XIAOZHI_STAGE compiling\n")
|
||||
|
||||
def test_merge_bin_enables_preview_mode(self):
|
||||
with mock.patch.object(build, "_run_idf") as run_idf:
|
||||
build.merge_bin(preview=True)
|
||||
@@ -609,31 +661,60 @@ class PreviewTargetTests(unittest.TestCase):
|
||||
|
||||
|
||||
class TargetConfigurationTests(unittest.TestCase):
|
||||
def test_same_target_skips_set_target(self):
|
||||
def test_sync_vscode_target_preserves_other_settings(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
settings = Path(temp_dir) / "settings.json"
|
||||
settings.write_text(
|
||||
'{\n'
|
||||
' "idf.customExtraVars": {\n'
|
||||
' "IDF_TARGET": "esp32c3"\n'
|
||||
' },\n'
|
||||
' "editor.formatOnSave": true\n'
|
||||
'}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertTrue(build._sync_vscode_target("esp32s3", settings))
|
||||
self.assertEqual(
|
||||
settings.read_text(encoding="utf-8"),
|
||||
'{\n'
|
||||
' "idf.customExtraVars": {\n'
|
||||
' "IDF_TARGET": "esp32s3"\n'
|
||||
' },\n'
|
||||
' "editor.formatOnSave": true\n'
|
||||
'}\n',
|
||||
)
|
||||
self.assertFalse(build._sync_vscode_target("esp32s3", settings))
|
||||
|
||||
def test_sync_vscode_target_skips_missing_settings(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
settings = Path(temp_dir) / "settings.json"
|
||||
self.assertFalse(build._sync_vscode_target("esp32s3", settings))
|
||||
|
||||
def test_same_target_does_not_clean_build_directory(self):
|
||||
with (
|
||||
mock.patch.object(build, "_target_from_cmake_cache", return_value="esp32s3"),
|
||||
mock.patch.object(build, "_configured_target", return_value="esp32s3"),
|
||||
mock.patch.object(build, "_run_idf") as run_idf,
|
||||
):
|
||||
changed = build._ensure_target("esp32s3", preview=False)
|
||||
build._prepare_target("esp32s3", preview=False)
|
||||
|
||||
run_idf.assert_not_called()
|
||||
self.assertFalse(changed)
|
||||
|
||||
def test_changed_target_calls_set_target(self):
|
||||
def test_changed_cmake_target_runs_fullclean_only(self):
|
||||
with (
|
||||
mock.patch.object(build, "_configured_target", return_value="esp32c3"),
|
||||
mock.patch.object(build, "_target_from_cmake_cache", return_value="esp32c3"),
|
||||
mock.patch.object(build, "_configured_target", return_value=None),
|
||||
mock.patch.object(build, "_run_idf") as run_idf,
|
||||
):
|
||||
changed = build._ensure_target("esp32s3", preview=True)
|
||||
build._prepare_target("esp32s3", preview=True)
|
||||
|
||||
run_idf.assert_called_once_with(
|
||||
"set-target",
|
||||
"esp32s3",
|
||||
"fullclean",
|
||||
preview=True,
|
||||
)
|
||||
self.assertTrue(changed)
|
||||
|
||||
def test_regenerate_sdkconfig_uses_clean_variant_fragment(self):
|
||||
def test_configure_build_uses_all_cmake_values_in_one_run(self):
|
||||
previous_cwd = Path.cwd()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
@@ -648,9 +729,10 @@ class TargetConfigurationTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
with mock.patch.object(build, "_run_idf") as run_idf:
|
||||
build._regenerate_sdkconfig(
|
||||
build._configure_build(
|
||||
"esp32s3",
|
||||
["CONFIG_BOARD_TYPE_TEST=y", "CONFIG_FEATURE=y"],
|
||||
"test-board",
|
||||
preview=False,
|
||||
)
|
||||
|
||||
@@ -670,13 +752,14 @@ class TargetConfigurationTests(unittest.TestCase):
|
||||
"-DIDF_TARGET=esp32s3",
|
||||
"-DSDKCONFIG_DEFAULTS="
|
||||
"sdkconfig.defaults;build/xiaozhi-build.sdkconfig.defaults",
|
||||
"-DBOARD_NAME=test-board",
|
||||
"reconfigure",
|
||||
preview=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(previous_cwd)
|
||||
|
||||
def test_target_change_preserves_set_target_backup(self):
|
||||
def test_configure_build_replaces_stale_sdkconfig_backup(self):
|
||||
previous_cwd = Path.cwd()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
@@ -691,17 +774,17 @@ class TargetConfigurationTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
with mock.patch.object(build, "_run_idf"):
|
||||
build._regenerate_sdkconfig(
|
||||
build._configure_build(
|
||||
"esp32s3",
|
||||
["CONFIG_BOARD_TYPE_TEST=y"],
|
||||
"test-board",
|
||||
preview=False,
|
||||
target_changed=True,
|
||||
)
|
||||
|
||||
self.assertFalse(Path("sdkconfig").exists())
|
||||
self.assertEqual(
|
||||
Path("sdkconfig.old").read_text(encoding="utf-8"),
|
||||
"CONFIG_USER_PREVIOUS_VALUE=y\n",
|
||||
'CONFIG_IDF_TARGET="esp32s3"\n',
|
||||
)
|
||||
finally:
|
||||
os.chdir(previous_cwd)
|
||||
|
||||
Reference in New Issue
Block a user