From df74d307c83ade59741627f1cccca13d28f71302 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:52:04 -0500 Subject: [PATCH] [esp32] Add support for native ESP-IDF builds (#13272) Co-authored-by: Claude Opus 4.5 Co-authored-by: J. Nick Koston --- esphome/__main__.py | 65 ++++++-- esphome/build_gen/espidf.py | 139 ++++++++++++++++ esphome/components/esp32/__init__.py | 139 ++++++++-------- esphome/components/esp32/const.py | 1 + esphome/const.py | 1 + esphome/core/__init__.py | 4 + esphome/espidf_api.py | 229 +++++++++++++++++++++++++++ 7 files changed, 502 insertions(+), 76 deletions(-) create mode 100644 esphome/build_gen/espidf.py create mode 100644 esphome/espidf_api.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 09d2855eb1..55297e8d9b 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -43,6 +43,7 @@ from esphome.const import ( CONF_SUBSTITUTIONS, CONF_TOPIC, ENV_NOGITIGNORE, + KEY_NATIVE_IDF, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, @@ -116,6 +117,7 @@ class ArgsProtocol(Protocol): configuration: str name: str upload_speed: str | None + native_idf: bool def choose_prompt(options, purpose: str = None): @@ -500,12 +502,15 @@ def wrap_to_code(name, comp): return wrapped -def write_cpp(config: ConfigType) -> int: +def write_cpp(config: ConfigType, native_idf: bool = False) -> int: if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() + # Store native_idf flag so esp32 component can check it + CORE.data[KEY_NATIVE_IDF] = native_idf + generate_cpp_contents(config) - return write_cpp_file() + return write_cpp_file(native_idf=native_idf) def generate_cpp_contents(config: ConfigType) -> None: @@ -519,32 +524,54 @@ def generate_cpp_contents(config: ConfigType) -> None: CORE.flush_tasks() -def write_cpp_file() -> int: +def write_cpp_file(native_idf: bool = False) -> int: code_s = indent(CORE.cpp_main_section) writer.write_cpp(code_s) - from esphome.build_gen import platformio + if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf": + from esphome.build_gen import espidf - platformio.write_project() + espidf.write_project() + else: + from esphome.build_gen import platformio + + platformio.write_project() return 0 def compile_program(args: ArgsProtocol, config: ConfigType) -> int: - from esphome import platformio_api + native_idf = getattr(args, "native_idf", False) # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) - rc = platformio_api.run_compile(config, CORE.verbose) - if rc != 0: - return rc + + if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf": + from esphome import espidf_api + + rc = espidf_api.run_compile(config, CORE.verbose) + if rc != 0: + return rc + + # Create factory.bin and ota.bin + espidf_api.create_factory_bin() + espidf_api.create_ota_bin() + else: + from esphome import platformio_api + + rc = platformio_api.run_compile(config, CORE.verbose) + if rc != 0: + return rc + + idedata = platformio_api.get_idedata(config) + if idedata is None: + return 1 # Check if firmware was rebuilt and emit build_info + create manifest _check_and_emit_build_info() - idedata = platformio_api.get_idedata(config) - return 0 if idedata is not None else 1 + return 0 def _check_and_emit_build_info() -> None: @@ -801,7 +828,8 @@ def command_vscode(args: ArgsProtocol) -> int | None: def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: - exit_code = write_cpp(config) + native_idf = getattr(args, "native_idf", False) + exit_code = write_cpp(config, native_idf=native_idf) if exit_code != 0: return exit_code if args.only_generate: @@ -856,7 +884,8 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: - exit_code = write_cpp(config) + native_idf = getattr(args, "native_idf", False) + exit_code = write_cpp(config, native_idf=native_idf) if exit_code != 0: return exit_code exit_code = compile_program(args, config) @@ -1310,6 +1339,11 @@ def parse_args(argv): help="Only generate source code, do not compile.", action="store_true", ) + parser_compile.add_argument( + "--native-idf", + help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).", + action="store_true", + ) parser_upload = subparsers.add_parser( "upload", @@ -1391,6 +1425,11 @@ def parse_args(argv): help="Reset the device before starting serial logs.", default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), ) + parser_run.add_argument( + "--native-idf", + help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).", + action="store_true", + ) parser_clean = subparsers.add_parser( "clean-mqtt", diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py new file mode 100644 index 0000000000..f45efb82c1 --- /dev/null +++ b/esphome/build_gen/espidf.py @@ -0,0 +1,139 @@ +"""ESP-IDF direct build generator for ESPHome.""" + +import json +from pathlib import Path + +from esphome.components.esp32 import get_esp32_variant +from esphome.core import CORE +from esphome.helpers import mkdir_p, write_file_if_changed + + +def get_available_components() -> list[str] | None: + """Get list of available ESP-IDF components from project_description.json. + + Returns only internal ESP-IDF components, excluding external/managed + components (from idf_component.yml). + """ + project_desc = Path(CORE.build_path) / "build" / "project_description.json" + if not project_desc.exists(): + return None + + try: + with open(project_desc, encoding="utf-8") as f: + data = json.load(f) + + component_info = data.get("build_component_info", {}) + + result = [] + for name, info in component_info.items(): + # Exclude our own src component + if name == "src": + continue + + # Exclude managed/external components + comp_dir = info.get("dir", "") + if "managed_components" in comp_dir: + continue + + result.append(name) + + return result + except (json.JSONDecodeError, OSError): + return None + + +def has_discovered_components() -> bool: + """Check if we have discovered components from a previous configure.""" + return get_available_components() is not None + + +def get_project_cmakelists() -> str: + """Generate the top-level CMakeLists.txt for ESP-IDF project.""" + # Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3) + variant = get_esp32_variant() + idf_target = variant.lower().replace("-", "") + + return f"""\ +# Auto-generated by ESPHome +cmake_minimum_required(VERSION 3.16) + +set(IDF_TARGET {idf_target}) +set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) + +include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +project({CORE.name}) +""" + + +def get_component_cmakelists(minimal: bool = False) -> str: + """Generate the main component CMakeLists.txt.""" + idf_requires = [] if minimal else (get_available_components() or []) + requires_str = " ".join(idf_requires) + + # Extract compile definitions from build flags (-DXXX -> XXX) + compile_defs = [flag[2:] for flag in CORE.build_flags if flag.startswith("-D")] + compile_defs_str = "\n ".join(compile_defs) if compile_defs else "" + + # Extract compile options (-W flags, excluding linker flags) + compile_opts = [ + flag + for flag in CORE.build_flags + if flag.startswith("-W") and not flag.startswith("-Wl,") + ] + compile_opts_str = "\n ".join(compile_opts) if compile_opts else "" + + # Extract linker options (-Wl, flags) + link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] + link_opts_str = "\n ".join(link_opts) if link_opts else "" + + return f"""\ +# Auto-generated by ESPHome +file(GLOB_RECURSE app_sources + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" +) + +idf_component_register( + SRCS ${{app_sources}} + INCLUDE_DIRS "." "esphome" + REQUIRES {requires_str} +) + +# Apply C++ standard +target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) + +# ESPHome compile definitions +target_compile_definitions(${{COMPONENT_LIB}} PUBLIC + {compile_defs_str} +) + +# ESPHome compile options +target_compile_options(${{COMPONENT_LIB}} PUBLIC + {compile_opts_str} +) + +# ESPHome linker options +target_link_options(${{COMPONENT_LIB}} PUBLIC + {link_opts_str} +) +""" + + +def write_project(minimal: bool = False) -> None: + """Write ESP-IDF project files.""" + mkdir_p(CORE.build_path) + mkdir_p(CORE.relative_src_path()) + + # Write top-level CMakeLists.txt + write_file_if_changed( + CORE.relative_build_path("CMakeLists.txt"), + get_project_cmakelists(), + ) + + # Write component CMakeLists.txt in src/ + write_file_if_changed( + CORE.relative_src_path("CMakeLists.txt"), + get_component_cmakelists(minimal=minimal), + ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 45fe8d1c26..07446ab046 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -34,6 +34,7 @@ from esphome.const import ( KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_NAME, + KEY_NATIVE_IDF, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_ESP32, @@ -53,6 +54,7 @@ from .const import ( # noqa KEY_COMPONENTS, KEY_ESP32, KEY_EXTRA_BUILD_FILES, + KEY_FLASH_SIZE, KEY_PATH, KEY_REF, KEY_REPO, @@ -199,6 +201,7 @@ def set_core_data(config): ) CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE] CORE.data[KEY_ESP32][KEY_VARIANT] = variant CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES] = {} @@ -962,12 +965,54 @@ async def _add_yaml_idf_components(components: list[ConfigType]): async def to_code(config): - cg.add_platformio_option("board", config[CONF_BOARD]) - cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) - cg.add_platformio_option( - "board_upload.maximum_size", - int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, - ) + framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + conf = config[CONF_FRAMEWORK] + + # Check if using native ESP-IDF build (--native-idf) + use_platformio = not CORE.data.get(KEY_NATIVE_IDF, False) + if use_platformio: + # Clear IDF environment variables to avoid conflicts with PlatformIO's ESP-IDF + # but keep them when using --native-idf for native ESP-IDF builds + for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): + os.environ.pop(clean_var, None) + + cg.add_platformio_option("lib_ldf_mode", "off") + cg.add_platformio_option("lib_compat_mode", "strict") + cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) + cg.add_platformio_option("board", config[CONF_BOARD]) + cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) + cg.add_platformio_option( + "board_upload.maximum_size", + int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, + ) + + if CONF_SOURCE in conf: + cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) + + add_extra_script( + "pre", + "pre_build.py", + Path(__file__).parent / "pre_build.py.script", + ) + + add_extra_script( + "post", + "post_build.py", + Path(__file__).parent / "post_build.py.script", + ) + + # In testing mode, add IRAM fix script to allow linking grouped component tests + # Similar to ESP8266's approach but for ESP-IDF + if CORE.testing_mode: + cg.add_build_flag("-DESPHOME_TESTING_MODE") + add_extra_script( + "pre", + "iram_fix.py", + Path(__file__).parent / "iram_fix.py.script", + ) + else: + cg.add_build_flag("-Wno-error=format") + cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_build_flag("-Wl,-z,noexecstack") @@ -977,79 +1022,49 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant]) cg.add_define(ThreadModel.MULTI_ATOMICS) - cg.add_platformio_option("lib_ldf_mode", "off") - cg.add_platformio_option("lib_compat_mode", "strict") - - framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - - conf = config[CONF_FRAMEWORK] - cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) - if CONF_SOURCE in conf: - cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) - if conf[CONF_ADVANCED][CONF_IGNORE_EFUSE_CUSTOM_MAC]: cg.add_define("USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC") - for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): - os.environ.pop(clean_var, None) - # Set the location of the IDF component manager cache os.environ["IDF_COMPONENT_CACHE_PATH"] = str( CORE.relative_internal_path(".espressif") ) - add_extra_script( - "pre", - "pre_build.py", - Path(__file__).parent / "pre_build.py.script", - ) - - add_extra_script( - "post", - "post_build.py", - Path(__file__).parent / "post_build.py.script", - ) - - # In testing mode, add IRAM fix script to allow linking grouped component tests - # Similar to ESP8266's approach but for ESP-IDF - if CORE.testing_mode: - cg.add_build_flag("-DESPHOME_TESTING_MODE") - add_extra_script( - "pre", - "iram_fix.py", - Path(__file__).parent / "iram_fix.py.script", - ) - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - cg.add_platformio_option("framework", "espidf") cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") + if use_platformio: + cg.add_platformio_option("framework", "espidf") else: - cg.add_platformio_option("framework", "arduino, espidf") cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") + if use_platformio: + cg.add_platformio_option("framework", "arduino, espidf") + + # Add IDF framework source for Arduino builds to ensure it uses the same version as + # the ESP-IDF framework + if (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: + cg.add_platformio_option( + "platform_packages", + [_format_framework_espidf_version(idf_ver, None)], + ) + + # ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency + if get_esp32_variant() == VARIANT_ESP32S2: + cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=1") + cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=0") + cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=0") + cg.add_define( "USE_ARDUINO_VERSION_CODE", cg.RawExpression( f"VERSION_CODE({framework_ver.major}, {framework_ver.minor}, {framework_ver.patch})" ), ) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) - # Add IDF framework source for Arduino builds to ensure it uses the same version as - # the ESP-IDF framework - if (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: - cg.add_platformio_option( - "platform_packages", [_format_framework_espidf_version(idf_ver, None)] - ) - - # ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency - if get_esp32_variant() == VARIANT_ESP32S2: - cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=1") - cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=0") - cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=0") - cg.add_build_flag("-Wno-nonnull-compare") add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) @@ -1196,7 +1211,8 @@ async def to_code(config): "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] ) - cg.add_platformio_option("board_build.partitions", "partitions.csv") + if use_platformio: + cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: add_extra_build_file( "partitions.csv", CORE.relative_config_path(config[CONF_PARTITIONS]) @@ -1361,19 +1377,16 @@ def copy_files(): _write_idf_component_yml() if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]: + flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE] if CORE.using_arduino: write_file_if_changed( CORE.relative_build_path("partitions.csv"), - get_arduino_partition_csv( - CORE.platformio_options.get("board_upload.flash_size") - ), + get_arduino_partition_csv(flash_size), ) else: write_file_if_changed( CORE.relative_build_path("partitions.csv"), - get_idf_partition_csv( - CORE.platformio_options.get("board_upload.flash_size") - ), + get_idf_partition_csv(flash_size), ) # IDF build scripts look for version string to put in the build. # However, if the build path does not have an initialized git repo, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index dfb736f615..2a9456db23 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -2,6 +2,7 @@ import esphome.codegen as cg KEY_ESP32 = "esp32" KEY_BOARD = "board" +KEY_FLASH_SIZE = "flash_size" KEY_VARIANT = "variant" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_COMPONENTS = "components" diff --git a/esphome/const.py b/esphome/const.py index c88d5811b4..d955387f7f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1379,6 +1379,7 @@ KEY_FRAMEWORK_VERSION = "framework_version" KEY_NAME = "name" KEY_VARIANT = "variant" KEY_PAST_SAFE_MODE = "past_safe_mode" +KEY_NATIVE_IDF = "native_idf" # Entity categories ENTITY_CATEGORY_NONE = "" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 70593d8153..9a7dd49609 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, + KEY_NATIVE_IDF, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_BK72XX, @@ -763,6 +764,9 @@ class EsphomeCore: @property def firmware_bin(self) -> Path: + # Check if using native ESP-IDF build (--native-idf) + if self.data.get(KEY_NATIVE_IDF, False): + return self.relative_build_path("build", f"{self.name}.bin") if self.is_libretiny: return self.relative_pioenvs_path(self.name, "firmware.uf2") return self.relative_pioenvs_path(self.name, "firmware.bin") diff --git a/esphome/espidf_api.py b/esphome/espidf_api.py new file mode 100644 index 0000000000..9e9c57bfbd --- /dev/null +++ b/esphome/espidf_api.py @@ -0,0 +1,229 @@ +"""ESP-IDF direct build API for ESPHome.""" + +import json +import logging +import os +from pathlib import Path +import shutil +import subprocess + +from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE +from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME +from esphome.core import CORE, EsphomeError + +_LOGGER = logging.getLogger(__name__) + + +def _get_idf_path() -> Path | None: + """Get IDF_PATH from environment or common locations.""" + # Check environment variable first + if "IDF_PATH" in os.environ: + path = Path(os.environ["IDF_PATH"]) + if path.is_dir(): + return path + + # Check common installation locations + common_paths = [ + Path.home() / "esp" / "esp-idf", + Path.home() / ".espressif" / "esp-idf", + Path("/opt/esp-idf"), + ] + + for path in common_paths: + if path.is_dir() and (path / "tools" / "idf.py").is_file(): + return path + + return None + + +def _get_idf_env() -> dict[str, str]: + """Get environment variables needed for ESP-IDF build. + + Requires the user to have sourced export.sh before running esphome. + """ + env = os.environ.copy() + + idf_path = _get_idf_path() + if idf_path is None: + raise EsphomeError( + "ESP-IDF not found. Please install ESP-IDF and source export.sh:\n" + " git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git ~/esp-idf\n" + " cd ~/esp-idf && ./install.sh\n" + " source ~/esp-idf/export.sh\n" + "See: https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/" + ) + + env["IDF_PATH"] = str(idf_path) + return env + + +def run_idf_py( + *args, cwd: Path | None = None, capture_output: bool = False +) -> int | str: + """Run idf.py with the given arguments.""" + idf_path = _get_idf_path() + if idf_path is None: + raise EsphomeError("ESP-IDF not found") + + env = _get_idf_env() + idf_py = idf_path / "tools" / "idf.py" + + cmd = ["python", str(idf_py)] + list(args) + + if cwd is None: + cwd = CORE.build_path + + _LOGGER.debug("Running: %s", " ".join(cmd)) + _LOGGER.debug(" in directory: %s", cwd) + + if capture_output: + result = subprocess.run( + cmd, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + _LOGGER.error("idf.py failed:\n%s", result.stderr) + return result.stdout + result = subprocess.run( + cmd, + cwd=cwd, + env=env, + check=False, + ) + return result.returncode + + +def run_reconfigure() -> int: + """Run cmake reconfigure only (no build).""" + return run_idf_py("reconfigure") + + +def run_compile(config, verbose: bool) -> int: + """Compile the ESP-IDF project. + + Uses two-phase configure to auto-discover available components: + 1. If no previous build, configure with minimal REQUIRES to discover components + 2. Regenerate CMakeLists.txt with discovered components + 3. Run full build + """ + from esphome.build_gen.espidf import has_discovered_components, write_project + + # Check if we need to do discovery phase + if not has_discovered_components(): + _LOGGER.info("Discovering available ESP-IDF components...") + write_project(minimal=True) + rc = run_reconfigure() + if rc != 0: + _LOGGER.error("Component discovery failed") + return rc + _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") + write_project(minimal=False) + + # Build + args = ["build"] + + if verbose: + args.append("-v") + + # Add parallel job limit if configured + if CONF_COMPILE_PROCESS_LIMIT in config.get(CONF_ESPHOME, {}): + limit = config[CONF_ESPHOME][CONF_COMPILE_PROCESS_LIMIT] + args.extend(["-j", str(limit)]) + + # Set the sdkconfig file + sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") + if sdkconfig_path.is_file(): + args.extend(["-D", f"SDKCONFIG={sdkconfig_path}"]) + + return run_idf_py(*args) + + +def get_firmware_path() -> Path: + """Get the path to the compiled firmware binary.""" + build_dir = CORE.relative_build_path("build") + return build_dir / f"{CORE.name}.bin" + + +def get_factory_firmware_path() -> Path: + """Get the path to the factory firmware (with bootloader).""" + build_dir = CORE.relative_build_path("build") + return build_dir / f"{CORE.name}.factory.bin" + + +def create_factory_bin() -> bool: + """Create factory.bin by merging bootloader, partition table, and app.""" + build_dir = CORE.relative_build_path("build") + flasher_args_path = build_dir / "flasher_args.json" + + if not flasher_args_path.is_file(): + _LOGGER.warning("flasher_args.json not found, cannot create factory.bin") + return False + + try: + with open(flasher_args_path, encoding="utf-8") as f: + flash_data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + _LOGGER.error("Failed to read flasher_args.json: %s", e) + return False + + # Get flash size from config + flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE] + + # Build esptool merge command + sections = [] + for addr, fname in sorted( + flash_data.get("flash_files", {}).items(), key=lambda kv: int(kv[0], 16) + ): + file_path = build_dir / fname + if file_path.is_file(): + sections.extend([addr, str(file_path)]) + else: + _LOGGER.warning("Flash file not found: %s", file_path) + + if not sections: + _LOGGER.warning("No flash sections found") + return False + + output_path = get_factory_firmware_path() + chip = flash_data.get("extra_esptool_args", {}).get("chip", "esp32") + + cmd = [ + "python", + "-m", + "esptool", + "--chip", + chip, + "merge_bin", + "--flash_size", + flash_size, + "--output", + str(output_path), + ] + sections + + _LOGGER.info("Creating factory.bin...") + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + + if result.returncode != 0: + _LOGGER.error("Failed to create factory.bin: %s", result.stderr) + return False + + _LOGGER.info("Created: %s", output_path) + return True + + +def create_ota_bin() -> bool: + """Copy the firmware to .ota.bin for ESPHome OTA compatibility.""" + firmware_path = get_firmware_path() + ota_path = firmware_path.with_suffix(".ota.bin") + + if not firmware_path.is_file(): + _LOGGER.warning("Firmware not found: %s", firmware_path) + return False + + shutil.copy(firmware_path, ota_path) + _LOGGER.info("Created: %s", ota_path) + return True