Update project version to 2.4.2 and enhance firmware builder with build options

- Incremented project version from 2.4.1 to 2.4.2 in CMakeLists.txt.
- Added support for `--build-options-json` in firmware_builder.py to allow custom build options.
- Implemented validation for build options to ensure correct data types and structure.
- Updated tests to cover new build options functionality and validation logic.
This commit is contained in:
Xiaoxia
2026-08-06 05:54:34 +08:00
parent 66bf9f7c1d
commit e9582601e0
25 changed files with 710 additions and 60 deletions
+1 -1
View File
@@ -9,5 +9,5 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# "Trim" the build. Include the minimal set of components, main, and anything it depends on.
idf_build_set_property(MINIMAL_BUILD ON)
set(PROJECT_VER "2.4.1")
set(PROJECT_VER "2.4.2")
project(xiaozhi)
+34 -1
View File
@@ -83,13 +83,19 @@ def parser() -> argparse.ArgumentParser:
description=(
"Build one XiaoZhi firmware board configuration. Arguments may also "
"be supplied through FIRMWARE_BOARD_DIR, FIRMWARE_BOARD_NAME, "
"FIRMWARE_LANGUAGE, and FIRMWARE_WAKE_WORD."
"FIRMWARE_LANGUAGE, FIRMWARE_WAKE_WORD, and "
"FIRMWARE_BUILD_OPTIONS."
)
)
result.add_argument("--board-dir", default=env("FIRMWARE_BOARD_DIR"))
result.add_argument("--board-name", default=env("FIRMWARE_BOARD_NAME"))
result.add_argument("--language", default=env("FIRMWARE_LANGUAGE"))
result.add_argument("--wake-word", default=env("FIRMWARE_WAKE_WORD"))
result.add_argument(
"--build-options-json",
default=env("FIRMWARE_BUILD_OPTIONS") or "{}",
help="Curated semantic build options as a JSON object",
)
result.add_argument(
"--source-dir",
type=Path,
@@ -135,6 +141,30 @@ def validate(args: argparse.Namespace) -> None:
raise ValueError(f"Invalid wake word: {args.wake_word!r}")
args.wake_word = normalized_wake_word
try:
build_options = json.loads(args.build_options_json)
except json.JSONDecodeError as error:
raise ValueError(f"Invalid build options JSON: {error}") from error
if not isinstance(build_options, dict):
raise ValueError("Build options JSON must contain an object")
invalid_values = [
key
for key, value in build_options.items()
if not isinstance(key, str) or not isinstance(value, (str, bool))
]
if invalid_values:
raise ValueError(
"Build option values must be strings or booleans: "
+ ", ".join(map(str, invalid_values))
)
args.build_options = build_options
args.build_options_json = json.dumps(
build_options,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
build_script = args.source_dir / "scripts/build.py"
if not build_script.is_file():
raise ValueError(f"Firmware build script not found: {build_script}")
@@ -316,6 +346,7 @@ def main(argv: Sequence[str] | None = None) -> int:
"board_name": args.board_name,
"language": args.language,
"wake_word": args.wake_word,
"build_options": args.build_options,
"firmware_version": project_version(args.source_dir),
"firmware_source_revision": env("FIRMWARE_SOURCE_REVISION") or "unknown",
"idf_version": command_output(["idf.py", "--version"], args.source_dir),
@@ -336,6 +367,8 @@ def main(argv: Sequence[str] | None = None) -> int:
args.language,
"--wake-word",
args.wake_word,
"--build-options-json",
args.build_options_json,
]
return_code = run_and_log(command, args.source_dir, log_path)
manifest["finished_at"] = utc_now()
@@ -70,6 +70,8 @@ sys.exit(%d)
"zh-CN",
"--wake-word",
"nihaoxiaozhi",
"--build-options-json",
'{"wifi_provisioning":"blufi","multiline_chat":false}',
"--source-dir",
str(source),
"--output-dir",
@@ -89,6 +91,16 @@ sys.exit(%d)
self.assertEqual(manifest["board_dir"], "xmini/c3")
self.assertEqual(manifest["board_type"], "xmini-c3")
self.assertEqual(manifest["board_name"], "xmini-c3")
self.assertEqual(
manifest["build_options"],
{"wifi_provisioning": "blufi", "multiline_chat": False},
)
build_log = (output / "build.log").read_text(encoding="utf-8")
self.assertIn("--build-options-json", build_log)
self.assertIn(
'{\\"multiline_chat\\":false,\\"wifi_provisioning\\":\\"blufi\\"}',
build_log,
)
self.assertNotIn("variant", manifest)
self.assertTrue(manifest["runtime_architecture"])
self.assertGreaterEqual(manifest["runtime_cpu_count"], 1)
@@ -224,6 +236,27 @@ sys.exit(%d)
)
self.assertEqual(exit_code, 2)
def test_rejects_invalid_build_options(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
source = self.create_source(Path(temporary))
exit_code = firmware_builder.main(
[
"--board-dir",
"xmini/c3",
"--board-name",
"xmini-c3",
"--language",
"en-US",
"--wake-word",
"disabled",
"--build-options-json",
'["not-an-object"]',
"--source-dir",
str(source),
]
)
self.assertEqual(exit_code, 2)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -967,6 +967,28 @@ menu "Camera Configuration"
comment "Warning: Please read the help text before modifying these settings."
config XIAOZHI_CAMERA_MIRROR_CONFIGURED
bool
default n
help
Internal guard used by the parameterized build flow. When enabled,
the common camera implementations apply the horizontal and vertical
mirror settings below after the sensor has been initialized.
config XIAOZHI_CAMERA_HMIRROR
bool "Mirror Camera Horizontally"
default n
depends on XIAOZHI_CAMERA_MIRROR_CONFIGURED
help
Flip the captured camera image across the vertical axis.
config XIAOZHI_CAMERA_VFLIP
bool "Mirror Camera Vertically"
default n
depends on XIAOZHI_CAMERA_MIRROR_CONFIGURED
help
Flip the captured camera image across the horizontal axis.
config XIAOZHI_CAMERA_ALLOW_JPEG_INPUT
bool "Allow JPEG Input"
default n
@@ -5,7 +5,7 @@
{
"name": "bread-compact-esp32-lcd",
"sdkconfig_append": [
"LCD_ST7789_240X240_7PIN=y"
"CONFIG_LCD_ST7789_240X240_7PIN=y"
]
}
]
+17
View File
@@ -17,6 +17,19 @@
#define TAG "Esp32Camera"
#if CONFIG_XIAOZHI_CAMERA_MIRROR_CONFIGURED
#if CONFIG_XIAOZHI_CAMERA_HMIRROR
static constexpr bool kConfiguredHMirror = true;
#else
static constexpr bool kConfiguredHMirror = false;
#endif
#if CONFIG_XIAOZHI_CAMERA_VFLIP
static constexpr bool kConfiguredVFlip = true;
#else
static constexpr bool kConfiguredVFlip = false;
#endif
#endif
Esp32Camera::Esp32Camera(const camera_config_t &config) {
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
@@ -29,6 +42,10 @@ Esp32Camera::Esp32Camera(const camera_config_t &config) {
if (s->id.PID == GC0308_PID) {
s->set_hmirror(s, 0); // Control camera mirror: 1 for mirror, 0 for normal
}
#if CONFIG_XIAOZHI_CAMERA_MIRROR_CONFIGURED
s->set_hmirror(s, kConfiguredHMirror ? 1 : 0);
s->set_vflip(s, kConfiguredVFlip ? 1 : 0);
#endif
ESP_LOGI(TAG, "Camera initialized: format=%d", config.pixel_format);
}
+18
View File
@@ -56,6 +56,19 @@
#define TAG "EspVideo"
#if CONFIG_XIAOZHI_CAMERA_MIRROR_CONFIGURED
#if CONFIG_XIAOZHI_CAMERA_HMIRROR
static constexpr bool kConfiguredHMirror = true;
#else
static constexpr bool kConfiguredHMirror = false;
#endif
#if CONFIG_XIAOZHI_CAMERA_VFLIP
static constexpr bool kConfiguredVFlip = true;
#else
static constexpr bool kConfiguredVFlip = false;
#endif
#endif
#if defined(CONFIG_CAMERA_SENSOR_SWAP_PIXEL_BYTE_ORDER) || defined(CONFIG_XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP)
#pragma message("CAMERA_SENSOR_SWAP_PIXEL_BYTE_ORDER or CONFIG_XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP is enabled; verify YUV422 image integrity")
#endif
@@ -271,6 +284,11 @@ EspVideo::EspVideo(const esp_video_init_config_t& config) {
return;
}
#if CONFIG_XIAOZHI_CAMERA_MIRROR_CONFIGURED
SetHMirror(kConfiguredHMirror);
SetVFlip(kConfiguredVFlip);
#endif
#ifdef CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE
frame_.width = setformat.fmt.pix.height;
frame_.height = setformat.fmt.pix.width;
@@ -5,6 +5,10 @@
"builds": [
{
"name": "esp32s3-korvo2-v3-rndis",
"build_options": {
"camera_hmirror": false,
"camera_vflip": true
},
"sdkconfig_append": [
]
}
@@ -381,9 +381,6 @@ private:
};
camera_ = new Esp32Camera(camera_config);
if (camera_ != nullptr) {
camera_->SetVFlip(true);
}
}
public:
@@ -5,6 +5,10 @@
"builds": [
{
"name": "esp32s3-korvo2-v3",
"build_options": {
"camera_hmirror": false,
"camera_vflip": true
},
"sdkconfig_append": [
]
}
@@ -385,9 +385,6 @@ private:
};
camera_ = new Esp32Camera(camera_config);
if(camera_ != nullptr) {
camera_->SetVFlip(true);
}
}
public:
@@ -93,8 +93,4 @@
#define CAMERA_XCLK_FREQ_HZ (20 * 1000 * 1000)
#define CAMERA_SCCB_FREQ_HZ (10 * 1000)
// 摄像头:OV3660 模组
#define CAMERA_VFLIP true
#define CAMERA_HMIRROR true
#endif // _BOARD_CONFIG_H_
@@ -7,6 +7,10 @@
{
"name": "esp32-s31-korvo-1",
"idf_version": ">=6.1",
"build_options": {
"camera_hmirror": true,
"camera_vflip": true
},
"sdkconfig_append": [
"CONFIG_CAMERA_OV3660=y"
]
@@ -306,10 +306,6 @@ private:
};
camera_ = new EspVideo(video_config);
// 方向是板级特定配置,见 config.h 的 CAMERA_VFLIP / CAMERA_HMIRROR
camera_->SetVFlip(CAMERA_VFLIP);
camera_->SetHMirror(CAMERA_HMIRROR);
}
public:
@@ -6,7 +6,7 @@
{
"name": "eda-tv-pro",
"sdkconfig_append": [
"LCD_ST7789_240X240_7PIN=y"
"CONFIG_LCD_ST7789_240X240_7PIN=y"
]
}
]
@@ -5,6 +5,10 @@
"builds": [
{
"name": "lilygo-t-cameraplus-s3",
"build_options": {
"camera_hmirror": true,
"camera_vflip": true
},
"sdkconfig_append": [
"CONFIG_SPIRAM_MODE_QUAD=y",
"CONFIG_CAMERA_OV2640=y",
@@ -15,6 +19,10 @@
{
"name": "lilygo-t-cameraplus-s3-v1.2",
"legacy_names": ["lilygo-t-cameraplus-s3_v1_2"],
"build_options": {
"camera_hmirror": true,
"camera_vflip": true
},
"sdkconfig_append": [
"CONFIG_SPIRAM_MODE_QUAD=y",
"CONFIG_CAMERA_OV2640=y",
@@ -271,8 +271,6 @@ private:
};
camera_ = new EspVideo(video_config);
camera_->SetVFlip(1);
camera_->SetHMirror(1);
}
void InitializeTools() {
+4
View File
@@ -5,6 +5,10 @@
"builds": [
{
"name": "m5stack-core-s3",
"build_options": {
"camera_hmirror": false,
"camera_vflip": false
},
"sdkconfig_append": [
"CONFIG_SPIRAM_MODE_QUAD=y",
"CONFIG_CAMERA_GC0308=y",
@@ -327,7 +327,6 @@ private:
};
camera_ = new EspVideo(video_config);
camera_->SetHMirror(false);
}
public:
+4
View File
@@ -4,6 +4,10 @@
"builds": [
{
"name": "otto-robot",
"build_options": {
"camera_hmirror": true,
"camera_vflip": true
},
"sdkconfig_append": [
"CONFIG_HTTPD_WS_SUPPORT=y",
"CONFIG_CAMERA_OV2640=y",
-15
View File
@@ -288,21 +288,6 @@ private:
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
camera_ = new Esp32Camera(config);
// 根据摄像头类型设置不同的翻转参数
switch (camera_type_) {
case OTTO_CAMERA_OV3660:
camera_->SetVFlip(true);
camera_->SetHMirror(true);
ESP_LOGI(TAG, "OV3660: 设置 VFlip=true, HMirror=true");
break;
case OTTO_CAMERA_OV2640:
default:
camera_->SetVFlip(true);
camera_->SetHMirror(true);
ESP_LOGI(TAG, "OV2640: 设置 VFlip=true, HMirror=true");
break;
}
return true;
} catch (...) {
camera_ = nullptr;
@@ -5,6 +5,10 @@
"builds": [
{
"name": "esp32-s3-cam-2",
"build_options": {
"camera_hmirror": false,
"camera_vflip": true
},
"sdkconfig_append": [
"CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_CAM_XXXX=y",
"CONFIG_USE_DEVICE_AEC=y",
@@ -13,6 +17,10 @@
},
{
"name": "esp32-s3-cam-2.8",
"build_options": {
"camera_hmirror": false,
"camera_vflip": true
},
"sdkconfig_append": [
"CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_CAM_XXXX=y",
"CONFIG_USE_DEVICE_AEC=y",
@@ -21,6 +29,10 @@
},
{
"name": "esp32-s3-cam-3.5",
"build_options": {
"camera_hmirror": false,
"camera_vflip": true
},
"sdkconfig_append": [
"CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_CAM_XXXX=y",
"CONFIG_USE_DEVICE_AEC=y",
@@ -29,6 +41,10 @@
},
{
"name": "esp32-s3-cam-1.83",
"build_options": {
"camera_hmirror": false,
"camera_vflip": true
},
"sdkconfig_append": [
"CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_CAM_XXXX=y",
"CONFIG_USE_DEVICE_AEC=y",
@@ -239,9 +239,6 @@ private:
};
camera_ = new Esp32Camera(camera_config);
if (camera_ != nullptr) {
camera_->SetVFlip(true);
}
}
public:
CustomBoard() :
+410 -16
View File
@@ -8,7 +8,7 @@ import argparse
import re
import subprocess
from pathlib import Path
from typing import Optional
from typing import Any, Optional
# Switch to project root directory
os.chdir(Path(__file__).resolve().parent.parent)
@@ -351,6 +351,317 @@ def _wake_word_sdkconfig_options(
_BOARDS_DIR = Path("main/boards")
_DISPLAY_STYLE_SYMBOLS = {
"default": "CONFIG_USE_DEFAULT_MESSAGE_STYLE",
"wechat": "CONFIG_USE_WECHAT_MESSAGE_STYLE",
"emote": "CONFIG_USE_EMOTE_MESSAGE_STYLE",
}
_DYNAMIC_CAMERA_MIRROR_BOARD_CONFIGS = {
# These boards intentionally change orientation at runtime according to the
# detected sensor or persisted device state. A compile-time override would
# be misleading because that runtime decision would win afterwards.
"CONFIG_BOARD_TYPE_DF_S3_AI_CAM",
"CONFIG_BOARD_TYPE_ESP_SPARKBOT",
"CONFIG_BOARD_TYPE_M5STACK_ATOM_S3R_CAM_M12_ECHO_BASE",
"CONFIG_BOARD_TYPE_SEEED_STUDIO_SENSECAP_WATCHER",
}
def _sdkconfig_assignments(options: list[str]) -> dict[str, str]:
"""Return the final value for each CONFIG_* assignment in a fragment."""
assignments: dict[str, str] = {}
for option in options:
key, separator, value = option.strip().partition("=")
if not separator or not key.startswith("CONFIG_"):
raise ValueError(f"Invalid sdkconfig assignment: {option!r}")
assignments[key] = value
return assignments
def _kconfig_choice(
name: str,
kconfig_path: Path = Path("main/Kconfig.projbuild"),
) -> dict[str, Any]:
"""Read a named project choice without trying to reimplement Kconfig."""
content = kconfig_path.read_text(encoding="utf-8")
start = re.search(rf"^choice\s+{re.escape(name)}\s*$", content, re.MULTILINE)
if not start:
raise ValueError(f"Kconfig choice {name} was not found in {kconfig_path}")
end = re.search(r"^endchoice\s*$", content[start.end():], re.MULTILINE)
if not end:
raise ValueError(f"Kconfig choice {name} has no endchoice")
block = content[start.end():start.end() + end.start()]
entries: list[dict[str, str]] = []
matches = list(re.finditer(r"^\s*config\s+([A-Za-z0-9_]+)\s*$", block, re.MULTILINE))
for index, match in enumerate(matches):
entry_end = matches[index + 1].start() if index + 1 < len(matches) else len(block)
entry = block[match.end():entry_end]
label = re.search(r'^\s*bool\s+"([^"]+)"', entry, re.MULTILINE)
if label:
entries.append({"value": match.group(1), "label": label.group(1)})
default = re.search(r"^\s*default\s+([A-Za-z0-9_]+)", block, re.MULTILINE)
return {
"entries": entries,
"default": default.group(1) if default else None,
"board_configs": {
f"CONFIG_{symbol}"
for symbol in re.findall(r"\b(BOARD_TYPE_[A-Za-z0-9_]+)\b", block)
},
"block": block,
}
def _kconfig_config_board_dependencies(
symbol: str,
kconfig_path: Path = Path("main/Kconfig.projbuild"),
) -> set[str]:
"""Return CONFIG_BOARD_TYPE_* names mentioned by one config entry."""
content = kconfig_path.read_text(encoding="utf-8")
start = re.search(rf"^\s*config\s+{re.escape(symbol)}\s*$", content, re.MULTILINE)
if not start:
raise ValueError(f"Kconfig config {symbol} was not found in {kconfig_path}")
remainder = content[start.end():]
end = re.search(r"^\s*(?:config\s+|endchoice\s*$|endmenu\s*$)", remainder, re.MULTILINE)
block = remainder[:end.start()] if end else remainder
return {
f"CONFIG_{item}"
for item in re.findall(r"\b(BOARD_TYPE_[A-Za-z0-9_]+)\b", block)
}
def _board_source_text(board: str) -> str:
board_dir = _BOARDS_DIR / board
parts: list[str] = []
for path in sorted(board_dir.rglob("*")):
if path.suffix in {".c", ".cc", ".cpp", ".h", ".hpp"}:
parts.append(path.read_text(encoding="utf-8", errors="replace"))
return "\n".join(parts)
def _selected_choice_default(
choice: dict[str, Any],
assignments: dict[str, str],
) -> str:
for entry in choice["entries"]:
if assignments.get(f"CONFIG_{entry['value']}") == "y":
return str(entry["value"])
default = choice.get("default")
if not default:
raise ValueError("Exposed Kconfig choice has no default")
return str(default)
def _build_option_definitions(
board: str,
target: str,
board_config: str,
build: dict[str, Any],
) -> list[dict[str, Any]]:
"""Describe the curated, board-compatible options exposed to callers."""
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"build {build.get('name')!r} sdkconfig_append must be a string list")
assignments = _sdkconfig_assignments(sdkconfig_append)
source = _board_source_text(board)
definitions: list[dict[str, Any]] = []
for choice_name in ("DISPLAY_OLED_TYPE", "DISPLAY_LCD_TYPE"):
choice = _kconfig_choice(choice_name)
if board_config not in choice["board_configs"]:
continue
entries = [entry for entry in choice["entries"] if entry["value"] != "LCD_CUSTOM"]
definitions.append({
"key": "display_model",
"type": "select",
"default": _selected_choice_default(choice, assignments),
"choices": entries,
})
break
# Message styles are implemented by the color LCD display path. OLED and
# no-display boards deliberately do not expose a selector that has no effect.
if re.search(r"\b[A-Za-z0-9_]*LcdDisplay\b", source):
style_choice = _kconfig_choice("DISPLAY_STYLE")
emote_boards = _kconfig_config_board_dependencies("USE_EMOTE_MESSAGE_STYLE")
style_choices = [
{"value": "default", "label": "Default"},
{"value": "wechat", "label": "WeChat"},
]
if board_config in emote_boards:
style_choices.append({"value": "emote", "label": "Emote animation"})
style_default = "default"
selected_style = _selected_choice_default(style_choice, assignments)
for value, symbol in _DISPLAY_STYLE_SYMBOLS.items():
if symbol == f"CONFIG_{selected_style}":
style_default = value
break
definitions.extend((
{
"key": "display_style",
"type": "select",
"default": style_default,
"choices": style_choices,
},
{
"key": "multiline_chat",
"type": "boolean",
"default": assignments.get("CONFIG_USE_MULTILINE_CHAT_MESSAGE") == "y",
},
))
aec_boards = _kconfig_config_board_dependencies("USE_DEVICE_AEC")
if board_config in aec_boards:
definitions.append({
"key": "aec_mode",
"type": "select",
"default": "device" if assignments.get("CONFIG_USE_DEVICE_AEC") == "y" else "off",
"choices": [
{"value": "off", "label": "Off"},
{"value": "device", "label": "Device-side AEC"},
],
})
# ESP32-P4 obtains networking through a companion chip and cannot enable
# the local ESP-BluFi stack selected by this project option.
if target != "esp32p4" and ("wifi_board.h" in source or re.search(r"\bWifiBoard\b", source)):
definitions.append({
"key": "wifi_provisioning",
"type": "select",
"default": (
"blufi"
if assignments.get("CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING") == "y"
and assignments.get("CONFIG_USE_HOTSPOT_WIFI_PROVISIONING") == "n"
else "hotspot"
),
"choices": [
{"value": "hotspot", "label": "Wi-Fi hotspot"},
{"value": "blufi", "label": "ESP-BluFi"},
],
})
has_common_camera = "new Esp32Camera" in source or "new EspVideo" in source
if has_common_camera and board_config not in _DYNAMIC_CAMERA_MIRROR_BOARD_CONFIGS:
definitions.extend((
{"key": "camera_hmirror", "type": "boolean", "default": False},
{"key": "camera_vflip", "type": "boolean", "default": False},
))
configured_defaults = build.get("build_options", {})
if not isinstance(configured_defaults, dict):
raise ValueError(f"build {build.get('name')!r} build_options must be an object")
by_key = {definition["key"]: definition for definition in definitions}
unknown_defaults = sorted(set(configured_defaults) - set(by_key))
if unknown_defaults:
raise ValueError(
f"build {build.get('name')!r} has unsupported build_options defaults: "
+ ", ".join(unknown_defaults)
)
for key, value in configured_defaults.items():
by_key[key]["default"] = value
# Validate board defaults through the same path used for caller input.
_normalize_build_options(definitions, {})
return definitions
def _normalize_build_options(
definitions: list[dict[str, Any]],
requested: object,
) -> dict[str, object]:
if not isinstance(requested, dict):
raise ValueError("--build-options-json must contain a JSON object")
by_key = {definition["key"]: definition for definition in definitions}
unknown = sorted(set(requested) - set(by_key))
if unknown:
raise ValueError("Unsupported build option(s): " + ", ".join(unknown))
normalized: dict[str, object] = {}
for key in sorted(by_key):
definition = by_key[key]
value = requested.get(key, definition["default"])
if definition["type"] == "boolean":
if not isinstance(value, bool):
raise ValueError(f"Build option {key} must be a boolean")
elif definition["type"] == "select":
allowed = {choice["value"] for choice in definition["choices"]}
if not isinstance(value, str) or value not in allowed:
raise ValueError(
f"Build option {key} must be one of: {', '.join(sorted(allowed))}"
)
normalized[key] = value
if normalized.get("display_style") != "default" and "multiline_chat" in normalized:
normalized["multiline_chat"] = False
return normalized
def _build_options_sdkconfig(
definitions: list[dict[str, Any]],
options: dict[str, object],
base_assignments: dict[str, str],
) -> list[str]:
"""Expand semantic build options into a complete, mutually-exclusive fragment."""
by_key = {definition["key"]: definition for definition in definitions}
result: list[str] = []
if "display_model" in options:
selected = options["display_model"]
for choice in by_key["display_model"]["choices"]:
result.append(f"CONFIG_{choice['value']}={'y' if choice['value'] == selected else 'n'}")
if isinstance(selected, str) and selected.startswith("LCD_"):
# LCD_CUSTOM is intentionally not exposed in the cloud UI because
# it requires source-level panel configuration, but it is still a
# sibling in the Kconfig choice and must be disabled explicitly.
result.append("CONFIG_LCD_CUSTOM=n")
if "display_style" in options:
selected = options["display_style"]
for value, symbol in _DISPLAY_STYLE_SYMBOLS.items():
result.append(f"{symbol}={'y' if value == selected else 'n'}")
flash_symbols = (
"CONFIG_FLASH_NONE_ASSETS",
"CONFIG_FLASH_DEFAULT_ASSETS",
"CONFIG_FLASH_CUSTOM_ASSETS",
"CONFIG_FLASH_EXPRESSION_ASSETS",
)
if selected == "emote" and base_assignments.get("CONFIG_FLASH_CUSTOM_ASSETS") != "y":
result.extend(
f"{symbol}={'y' if symbol == 'CONFIG_FLASH_EXPRESSION_ASSETS' else 'n'}"
for symbol in flash_symbols
)
elif selected != "emote" and base_assignments.get("CONFIG_FLASH_EXPRESSION_ASSETS") == "y":
result.extend(
f"{symbol}={'y' if symbol == 'CONFIG_FLASH_DEFAULT_ASSETS' else 'n'}"
for symbol in flash_symbols
)
if "multiline_chat" in options:
result.append(f"CONFIG_USE_MULTILINE_CHAT_MESSAGE={'y' if options['multiline_chat'] else 'n'}")
if "aec_mode" in options:
device = options["aec_mode"] == "device"
result.extend((
f"CONFIG_USE_DEVICE_AEC={'y' if device else 'n'}",
"CONFIG_USE_SERVER_AEC=n",
))
if device:
result.append("CONFIG_USE_AUDIO_PROCESSOR=y")
if "wifi_provisioning" in options:
blufi = options["wifi_provisioning"] == "blufi"
result.extend((
f"CONFIG_USE_HOTSPOT_WIFI_PROVISIONING={'n' if blufi else 'y'}",
f"CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING={'y' if blufi else 'n'}",
))
if "camera_hmirror" in options or "camera_vflip" in options:
result.extend((
"CONFIG_XIAOZHI_CAMERA_MIRROR_CONFIGURED=y",
f"CONFIG_XIAOZHI_CAMERA_HMIRROR={'y' if options.get('camera_hmirror') else 'n'}",
f"CONFIG_XIAOZHI_CAMERA_VFLIP={'y' if options.get('camera_vflip') else 'n'}",
))
return result
def _parse_version(value: str) -> tuple[int, int, int]:
"""Parse an ESP-IDF version string such as 5.5.4 or v6.0."""
@@ -462,7 +773,7 @@ def _get_board_display_name(
def _collect_variants(
config_filename: str = "config.json",
idf_version: tuple[int, int, int] = (6, 0, 0),
) -> list[dict[str, str]]:
) -> list[dict[str, Any]]:
"""Traverse all boards under main/boards, collect variant information.
Return example:
@@ -599,6 +910,12 @@ def _collect_variants(
)
variant["config"] = config_symbol
variant["display_name"] = _get_board_display_name(config_symbol)
variant["build_options"] = _build_option_definitions(
variant["board"],
variant["target"],
config_symbol,
build,
)
return sorted(variants, key=lambda variant: (variant["board"], variant["name"]))
@@ -830,26 +1147,17 @@ _AUTO_SELECT_RULES: dict[str, list[str]] = {
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)
items = list(sdkconfig_append)
# 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)
# A board preset may explicitly disable a dependency. Kconfig's
# select would override it, so the generated defaults fragment
# must do the same instead of keeping the earlier value.
items = _merge_sdkconfig_options(items, deps)
break
return items
@@ -1017,6 +1325,36 @@ def _validate_configured_symbols(symbols: list[str], option_name: str) -> None:
)
def _validate_configured_options(options: list[str], option_name: str) -> None:
"""Ensure Kconfig accepted each requested y/n semantic build option."""
if not options:
return
sdkconfig = Path("sdkconfig")
if not sdkconfig.exists():
raise RuntimeError(f"Cannot validate {option_name}: sdkconfig was not generated")
content = sdkconfig.read_text(encoding="utf-8")
rejected: list[str] = []
for option in options:
key, _, expected = option.partition("=")
if expected == "y":
accepted = bool(re.search(rf"^{re.escape(key)}=y$", content, re.MULTILINE))
elif expected == "n":
accepted = bool(re.search(
rf"^(?:# {re.escape(key)} is not set|{re.escape(key)}=n)$",
content,
re.MULTILINE,
))
else:
continue
if not accepted:
rejected.append(option)
if rejected:
raise ValueError(
f"{option_name} is incompatible with this board or ESP-IDF "
f"configuration; Kconfig rejected: {', '.join(rejected)}"
)
def build_board(
board_type: str,
config_filename: str = "config.json",
@@ -1025,6 +1363,7 @@ def build_board(
create_zip: bool = False,
language: Optional[str] = None,
wake_word: Optional[str] = None,
build_options: Optional[dict[str, object]] = None,
idf_version: tuple[int, int, int] = (6, 0, 0),
) -> None:
"""Compile one specified variant of the specified board type.
@@ -1036,6 +1375,7 @@ def build_board(
create_zip: package merged-binary.bin under releases/ when true
language: optional locale such as en-US
wake_word: optional ESP-SR model name or "disabled"
build_options: optional semantic, board-validated option values
"""
cfg_path = _BOARDS_DIR / Path(board_type) / config_filename
if not cfg_path.exists():
@@ -1090,10 +1430,19 @@ def build_board(
sdkconfig_append = [f"{board_type_config}=y"]
sdkconfig_append.extend(build_sdkconfig_append)
option_definitions = _build_option_definitions(
board_type,
target,
board_type_config if not explicit_board_cfg else explicit_board_cfg,
build,
)
user_options: list[str] = []
validation_symbols: list[tuple[list[str], str]] = []
build_option_sdkconfig: list[str] = []
selected_language = None
selected_wake_word = None
selected_build_options: Optional[dict[str, object]] = None
if language is not None:
selected_language, option = _language_sdkconfig_option(language)
user_options.append(option)
@@ -1109,6 +1458,20 @@ def build_board(
user_options.extend(wake_word_options)
validation_symbols.append((wake_word_symbols, "--wake-word"))
# A build entry may carry semantic defaults to preserve an existing
# board-specific behavior after it moves out of hardcoded C++.
if build_options is not None or "build_options" in build:
selected_build_options = _normalize_build_options(
option_definitions,
build_options or {},
)
build_option_sdkconfig = _build_options_sdkconfig(
option_definitions,
selected_build_options,
_sdkconfig_assignments(build_sdkconfig_append),
)
user_options.extend(build_option_sdkconfig)
sdkconfig_append = _merge_sdkconfig_options(
sdkconfig_append,
user_options,
@@ -1127,6 +1490,11 @@ def build_board(
print(f"language: {selected_language}")
if selected_wake_word:
print(f"wake_word: {selected_wake_word}")
if selected_build_options is not None:
print(
"build_options: "
+ json.dumps(selected_build_options, ensure_ascii=False, sort_keys=True)
)
for item in sdkconfig_append:
print(f"sdkconfig_append: {item}")
@@ -1141,6 +1509,7 @@ def build_board(
)
for symbols, option_name in validation_symbols:
_validate_configured_symbols(symbols, option_name)
_validate_configured_options(build_option_sdkconfig, "--build-options-json")
# build.name is the compatibility-sensitive OTA-reported board identity.
_emit_build_stage("compiling")
@@ -1240,6 +1609,14 @@ def main(argv: Optional[list[str]] = None) -> None:
"'nihaoxiaozhi', or 'disabled'"
),
)
parser.add_argument(
"--build-options-json",
metavar="JSON",
help=(
"Semantic board options as a JSON object. Accepted keys are "
"reported by --list-boards --json for the selected variant."
),
)
parser.add_argument(
"--zip",
action="store_true",
@@ -1266,6 +1643,7 @@ def main(argv: Optional[list[str]] = None) -> None:
or args.name
or args.language
or args.wake_word
or args.build_options_json
or args.zip
or args.json
):
@@ -1284,6 +1662,7 @@ def main(argv: Optional[list[str]] = None) -> None:
or args.name
or args.language
or args.wake_word
or args.build_options_json
or args.zip
):
parser.error(
@@ -1304,6 +1683,7 @@ def main(argv: Optional[list[str]] = None) -> None:
or args.name
or args.language
or args.wake_word
or args.build_options_json
or args.zip
):
parser.error(
@@ -1331,6 +1711,7 @@ def main(argv: Optional[list[str]] = None) -> None:
or args.name
or args.language
or args.wake_word
or args.build_options_json
):
parser.error(
"--list-boards cannot be combined with build or other "
@@ -1355,6 +1736,18 @@ def main(argv: Optional[list[str]] = None) -> None:
parser.error("--json is only valid when listing boards")
if board_type_input == "all" and name_filter:
parser.error("--name cannot be combined with board 'all'")
if board_type_input == "all" and args.build_options_json:
parser.error("--build-options-json cannot be combined with board 'all'")
parsed_build_options: Optional[dict[str, object]] = None
if args.build_options_json is not None:
try:
raw_build_options = json.loads(args.build_options_json)
except json.JSONDecodeError as error:
parser.error(f"--build-options-json is invalid JSON: {error}")
if not isinstance(raw_build_options, dict):
parser.error("--build-options-json must contain a JSON object")
parsed_build_options = raw_build_options
# Check board_type in CMakeLists
if board_type_input != "all" and not _board_type_exists(board_type_input):
@@ -1397,6 +1790,7 @@ def main(argv: Optional[list[str]] = None) -> None:
create_zip=args.zip,
language=args.language,
wake_word=args.wake_word,
build_options=parsed_build_options,
idf_version=idf_version,
)
+124
View File
@@ -971,6 +971,111 @@ class BuildOptionTests(unittest.TestCase):
finally:
os.chdir(previous_cwd)
def test_lcd_board_exposes_curated_display_options(self):
config = json.loads(
(ROOT / "main/boards/bread-compact-esp32-lcd/config.json").read_text(
encoding="utf-8"
)
)
build_config = config["builds"][0]
board_config = build._resolve_board_config(
"bread-compact-esp32-lcd",
config["target"],
build_config["sdkconfig_append"],
variant_name=build_config["name"],
)
definitions = build._build_option_definitions(
"bread-compact-esp32-lcd",
config["target"],
board_config,
build_config,
)
by_key = {definition["key"]: definition for definition in definitions}
self.assertEqual(by_key["display_model"]["default"], "LCD_ST7789_240X240_7PIN")
self.assertNotIn(
"LCD_CUSTOM",
{choice["value"] for choice in by_key["display_model"]["choices"]},
)
self.assertIn("display_style", by_key)
self.assertIn("multiline_chat", by_key)
normalized = build._normalize_build_options(
definitions,
{"display_model": "LCD_ST7789_240X320"},
)
sdkconfig = build._build_options_sdkconfig(definitions, normalized, {})
self.assertIn("CONFIG_LCD_CUSTOM=n", sdkconfig)
def test_non_default_style_disables_multiline_chat(self):
definitions = [
{
"key": "display_style",
"type": "select",
"default": "default",
"choices": [
{"value": "default", "label": "Default"},
{"value": "wechat", "label": "WeChat"},
],
},
{"key": "multiline_chat", "type": "boolean", "default": True},
]
normalized = build._normalize_build_options(
definitions,
{"display_style": "wechat", "multiline_chat": True},
)
self.assertFalse(normalized["multiline_chat"])
def test_blufi_expansion_disables_hotspot(self):
definitions = [{
"key": "wifi_provisioning",
"type": "select",
"default": "hotspot",
"choices": [
{"value": "hotspot", "label": "Wi-Fi hotspot"},
{"value": "blufi", "label": "ESP-BluFi"},
],
}]
options = build._build_options_sdkconfig(
definitions,
{"wifi_provisioning": "blufi"},
{},
)
self.assertIn("CONFIG_USE_HOTSPOT_WIFI_PROVISIONING=n", options)
self.assertIn("CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING=y", options)
def test_camera_board_defaults_are_declared_by_board_config(self):
config = json.loads(
(ROOT / "main/boards/espressif/esp32-s3-korvo-2-v3.0/config.json").read_text(
encoding="utf-8"
)
)
build_config = config["builds"][0]
board_config = build._resolve_board_config(
"espressif/esp32-s3-korvo-2-v3.0",
config["target"],
build_config["sdkconfig_append"],
variant_name=build_config["name"],
)
definitions = build._build_option_definitions(
"espressif/esp32-s3-korvo-2-v3.0",
config["target"],
board_config,
build_config,
)
defaults = {definition["key"]: definition["default"] for definition in definitions}
self.assertFalse(defaults["camera_hmirror"])
self.assertTrue(defaults["camera_vflip"])
def test_unknown_semantic_build_option_is_rejected(self):
with self.assertRaisesRegex(ValueError, "Unsupported build option"):
build._normalize_build_options([], {"raw_sdkconfig": "CONFIG_FOO=y"})
class VariantSelectionTests(unittest.TestCase):
def setUp(self):
@@ -1108,6 +1213,7 @@ class CliTests(unittest.TestCase):
create_zip=False,
language=None,
wake_word=None,
build_options=None,
idf_version=(6, 0, 2),
)
@@ -1151,6 +1257,24 @@ class CliTests(unittest.TestCase):
"wn9_jarvis_tts",
)
def test_build_options_json_is_forwarded(self):
with (
mock.patch.object(build, "_detect_idf_version", return_value=(6, 0, 2)),
mock.patch.object(build, "_board_type_exists", return_value=True),
mock.patch.object(build, "_collect_variants", return_value=self.variants),
mock.patch.object(build, "build_board") as build_board,
):
build.main([
"bread-compact-wifi",
"--build-options-json",
'{"wifi_provisioning":"blufi"}',
])
self.assertEqual(
build_board.call_args.kwargs["build_options"],
{"wifi_provisioning": "blufi"},
)
class BoardSourceTests(unittest.TestCase):
def test_relative_board_includes_exist(self):