Enhance firmware builder with failure summary and error reporting

- Added `failure_summary` function to extract actionable errors from compiler logs.
- Updated manifest to include error details when firmware build fails.
- Added unit tests to verify the functionality of the new failure summary feature.
This commit is contained in:
Xiaoxia
2026-08-07 00:46:05 +08:00
parent 3ac6d89d0a
commit 85ff44a759
8 changed files with 111 additions and 16 deletions
@@ -284,6 +284,31 @@ def write_manifest(output_dir: Path, manifest: dict[str, object]) -> None:
temporary.replace(path)
def failure_summary(log_path: Path) -> str:
"""Extract a concise actionable failure from a compiler log."""
if not log_path.is_file():
return "Firmware build failed"
ansi_escape = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
lines = [
ansi_escape.sub("", line).strip()
for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines()
]
meaningful = [
line for line in lines
if line
and not line.startswith(("XIAOZHI_STAGE ", "XIAOZHI_SOURCE_REVISION "))
]
patterns = (
re.compile(r"(?:fatal error|error:|ValueError:|RuntimeError:|FileNotFoundError:)", re.I),
re.compile(r"(?:Kconfig rejected|Unsupported build option|build stopped|failed with exit code)", re.I),
)
for pattern in patterns:
for line in reversed(meaningful):
if pattern.search(line):
return line[:500]
return meaningful[-1][:500] if meaningful else "Firmware build failed"
def is_retryable_oss_error(error: Exception, oss2: Any) -> bool:
exceptions = getattr(oss2, "exceptions", None)
request_error = getattr(exceptions, "RequestError", None)
@@ -447,6 +472,7 @@ def main(argv: Sequence[str] | None = None) -> int:
manifest["exit_code"] = return_code
else:
manifest["status"] = "failed"
manifest["error"] = failure_summary(log_path)
write_manifest(args.output_dir, manifest)
if upload_config is not None:
@@ -135,6 +135,23 @@ sys.exit(%d)
manifest = json.loads((output / "manifest.json").read_text())
self.assertEqual(manifest["status"], "failed")
self.assertEqual(manifest["exit_code"], 7)
self.assertEqual(manifest["error"], "fake compiler output")
def test_failure_summary_prefers_compiler_error(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
log_path = Path(temporary) / "build.log"
log_path.write_text(
"FAILED: component.o\n"
"config.h:48:2: error: OLED display type is not selected\n"
"ninja: build stopped: subcommand failed.\n"
"XIAOZHI_STAGE uploading\n",
encoding="utf-8",
)
self.assertEqual(
firmware_builder.failure_summary(log_path),
"config.h:48:2: error: OLED display type is not selected",
)
def test_success_uploads_outputs_and_manifest_last(self) -> None:
uploads: list[tuple[str, str]] = []
+3 -3
View File
@@ -577,6 +577,9 @@ choice BOARD_TYPE
config BOARD_TYPE_WDMOMO_CGC_144
bool "WDMomo ESP32-CGC-144"
depends on IDF_TARGET_ESP32
config BOARD_TYPE_WK_ESP32S3_DEV
bool "WK ESP32-S3 Dev Board (维控智能开发板)"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_XMINI_C3
bool "Xmini C3"
depends on IDF_TARGET_ESP32C3
@@ -607,9 +610,6 @@ choice BOARD_TYPE
config BOARD_TYPE_ZHENGCHEN_CAM
bool "Zhengchen AI Camera Wi-Fi (征辰科技)"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WK_ESP32S3_DEV
bool "维控智能开发板"
depends on IDF_TARGET_ESP32S3
endchoice
choice XIAOZHI_NETWORK_TYPE
+1 -1
View File
@@ -42,7 +42,7 @@
#if CONFIG_OLED_SSD1306_128X32
#define DISPLAY_HEIGHT 32
#elif CONFIG_OLED_SSD1306_128X64
#elif CONFIG_OLED_SSD1306_128X64 || CONFIG_OLED_SH1106_128X64
#define DISPLAY_HEIGHT 64
#else
#error "OLED display type is not selected"
@@ -6,11 +6,11 @@
{
"name": "esp32-s3-touch-lcd-4.3c",
"sdkconfig_append": [
"CONFIG_BOARD_TYPE_ESP32S3_Touch_LCD_4_3C=y",
"CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4_3C=y",
"CONFIG_USE_DEVICE_AEC=y",
"CONFIG_SPIRAM_FETCH_INSTRUCTIONS=y",
"CONFIG_SPIRAM_RODATA=y"
]
}
]
}
}
+2 -1
View File
@@ -1,8 +1,9 @@
{
"type": "wk-esp32s3-dev",
"target": "esp32s3",
"builds": [
{
"name": "wk-esp32s3-dev"
}
]
}
}
+44 -9
View File
@@ -296,6 +296,31 @@ def _enabled_default_wake_word_symbols(target: str) -> list[str]:
return symbols
def _board_supports_wake_word(
target: str,
sdkconfig_append: list[str],
) -> bool:
"""Return whether one board variant satisfies the wake-word Kconfig deps."""
if target in _LITE_WAKE_WORD_TARGETS:
return True
if target not in _AFE_WAKE_WORD_TARGETS | {"esp32"}:
return False
defaults: list[str] = []
for path in (Path("sdkconfig.defaults"), Path(f"sdkconfig.defaults.{target}")):
if not path.exists():
continue
defaults.extend(
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip().startswith("CONFIG_") and "=" in line
)
assignments = _sdkconfig_assignments(
_merge_sdkconfig_options(defaults, sdkconfig_append)
)
return assignments.get("CONFIG_SPIRAM") == "y"
def _wake_word_sdkconfig_options(
wake_word: str,
target: str,
@@ -925,6 +950,10 @@ def _collect_variants(
)
variant["config"] = config_symbol
variant["display_name"] = _get_board_display_name(config_symbol)
variant["wake_word_supported"] = _board_supports_wake_word(
variant["target"],
sdkconfig_append,
)
variant["build_options"] = _build_option_definitions(
variant["board"],
variant["target"],
@@ -1432,26 +1461,32 @@ def build_board(
# 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:
board_type_config = _resolve_board_config(
board_type,
target,
build_sdkconfig_append,
variant_name=name,
)
if explicit_board_cfg == board_type_config:
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,
variant_name=name,
)
# Replace a stale/misspelled explicit symbol with the canonical
# Kconfig symbol. Listing and building must resolve board identity
# through the same path or their exposed options can diverge.
sdkconfig_append = [f"{board_type_config}=y"]
sdkconfig_append.extend(build_sdkconfig_append)
sdkconfig_append.extend(
item for item in build_sdkconfig_append
if item.strip() != f"{explicit_board_cfg}=y"
)
option_definitions = _build_option_definitions(
board_type,
target,
board_type_config if not explicit_board_cfg else explicit_board_cfg,
board_type_config,
build,
)
+16
View File
@@ -930,6 +930,16 @@ class BuildOptionTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "Invalid wake word"):
build._wake_word_sdkconfig_options("jarvis", "esp32s3")
def test_board_wake_word_support_obeys_psram_dependency(self):
self.assertTrue(build._board_supports_wake_word("esp32c3", []))
self.assertFalse(build._board_supports_wake_word("esp32", []))
self.assertTrue(
build._board_supports_wake_word("esp32", ["CONFIG_SPIRAM=y"])
)
self.assertFalse(
build._board_supports_wake_word("esp32s3", ["CONFIG_SPIRAM=n"])
)
def test_user_options_override_board_options(self):
merged = build._merge_sdkconfig_options(
[
@@ -1046,6 +1056,12 @@ class BuildOptionTests(unittest.TestCase):
sdkconfig = build._build_options_sdkconfig(definitions, normalized, {})
self.assertIn("CONFIG_LCD_CUSTOM=n", sdkconfig)
def test_bread_compact_esp32_config_supports_sh1106(self):
config_header = (
ROOT / "main/boards/bread-compact-esp32/config.h"
).read_text(encoding="utf-8")
self.assertIn("CONFIG_OLED_SH1106_128X64", config_header)
def test_non_default_style_disables_multiline_chat(self):
definitions = [
{