[core] Native idf full support (#14678)

Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
Diorcet Yann
2026-05-11 04:12:07 +02:00
committed by GitHub
parent 66e2dcffc4
commit e9cc10fedc
27 changed files with 3537 additions and 364 deletions
+86
View File
@@ -783,6 +783,91 @@ jobs:
# Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv"
test-native-idf:
name: Test components with native ESP-IDF
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request'
env:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache ESPHome
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
- name: Run native ESP-IDF compile test
run: |
. venv/bin/activate
# Check if /mnt has more free space than / before bind mounting
# Extract available space in KB for comparison
root_avail=$(df -k / | awk 'NR==2 {print $4}')
mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}')
echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB"
# Only use /mnt if it has more space than /
if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then
echo "Using /mnt for build files (more space available)"
# Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there)
sudo mkdir -p /mnt/esphome-idf
sudo chown $USER:$USER /mnt/esphome-idf
mkdir -p ~/.esphome-idf
sudo mount --bind /mnt/esphome-idf ~/.esphome-idf
# Bind mount test build directory to /mnt
sudo mkdir -p /mnt/test_build_components_build
sudo chown $USER:$USER /mnt/test_build_components_build
mkdir -p tests/test_build_components/build
sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build
else
echo "Using / for build files (more space available than /mnt or /mnt unavailable)"
fi
echo "Testing components: $TEST_COMPONENTS"
echo ""
# Show disk space before validation (after bind mounts setup)
echo "Disk space before config validation:"
df -h
echo ""
# Run config validation (auto-grouped by test_build_components.py)
python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf
echo ""
echo "Config validation passed! Starting compilation..."
echo ""
# Show disk space before compilation
echo "Disk space before compilation:"
df -h
echo ""
# Run compilation (auto-grouped by test_build_components.py)
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf
- name: Save ESPHome cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
@@ -1114,6 +1199,7 @@ jobs:
- determine-jobs
- device-builder
- test-build-components-split
- test-native-idf
- pre-commit-ci-lite
- memory-impact-target-branch
- memory-impact-pr-branch
+80 -44
View File
@@ -52,12 +52,12 @@ from esphome.const import (
CONF_WEB_SERVER,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_NATIVE_IDF,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
SECRETS_FILES,
Toolchain,
)
from esphome.core import CORE, EsphomeError, coroutine
from esphome.enum import StrEnum
@@ -155,7 +155,6 @@ class ArgsProtocol(Protocol):
configuration: str
name: str
upload_speed: str | None
native_idf: bool
def choose_prompt(options, purpose: str = None):
@@ -720,17 +719,14 @@ def _wrap_to_code(name, comp, yaml_util):
return wrapped
def write_cpp(config: ConfigType, native_idf: bool = False) -> int:
def write_cpp(config: ConfigType) -> int:
from esphome import writer
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(native_idf=native_idf)
return write_cpp_file()
def generate_cpp_contents(config: ConfigType) -> None:
@@ -746,13 +742,13 @@ def generate_cpp_contents(config: ConfigType) -> None:
CORE.flush_tasks()
def write_cpp_file(native_idf: bool = False) -> int:
def write_cpp_file() -> int:
from esphome import writer
code_s = indent(CORE.cpp_main_section)
writer.write_cpp(code_s)
if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf":
if CORE.using_toolchain_esp_idf:
from esphome.build_gen import espidf
espidf.write_project()
@@ -765,22 +761,21 @@ def write_cpp_file(native_idf: bool = False) -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
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)
if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf":
from esphome import espidf_api
if CORE.using_toolchain_esp_idf:
from esphome.espidf import api
rc = espidf_api.run_compile(config, CORE.verbose)
rc = 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()
# Create factory.bin, ota.bin, and firmware.elf copy
api.create_factory_bin()
api.create_ota_bin()
api.create_elf_copy()
else:
from esphome import platformio_api
@@ -883,6 +878,10 @@ def upload_using_esptool(
if file is not None:
flash_images = [FlashImage(path=file, offset="0x0")]
elif CORE.using_toolchain_esp_idf:
from esphome.espidf import api
flash_images = [FlashImage(path=api.get_factory_firmware_path(), offset="0x0")]
else:
from esphome import platformio_api
@@ -1447,8 +1446,7 @@ def command_vscode(args: ArgsProtocol) -> int | None:
def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
native_idf = getattr(args, "native_idf", False)
exit_code = write_cpp(config, native_idf=native_idf)
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
if args.only_generate:
@@ -1458,9 +1456,14 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
if exit_code != 0:
return exit_code
if CORE.is_host:
from esphome.platformio_api import get_idedata
if CORE.using_toolchain_esp_idf:
from esphome.espidf import api
program_path = str(get_idedata(config).firmware_elf_path)
program_path = str(api.get_elf_path())
else:
from esphome.platformio_api import get_idedata
program_path = str(get_idedata(config).firmware_elf_path)
_LOGGER.info("Successfully compiled program to path '%s'", program_path)
else:
_LOGGER.info("Successfully compiled program.")
@@ -1503,8 +1506,7 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
native_idf = getattr(args, "native_idf", False)
exit_code = write_cpp(config, native_idf=native_idf)
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
exit_code = compile_program(args, config)
@@ -1512,9 +1514,14 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
return exit_code
_LOGGER.info("Successfully compiled program.")
if CORE.is_host:
from esphome.platformio_api import get_idedata
if CORE.using_toolchain_esp_idf:
from esphome.espidf import api
program_path = str(get_idedata(config).firmware_elf_path)
program_path = str(api.get_elf_path())
else:
from esphome.platformio_api import get_idedata
program_path = str(get_idedata(config).firmware_elf_path)
_LOGGER.info("Running program from path '%s'", program_path)
return run_external_process(program_path)
@@ -1705,6 +1712,13 @@ def command_update_all(args: ArgsProtocol) -> int | None:
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
if not CORE.using_toolchain_platformio:
_LOGGER.error(
"The idedata command is not compatible with %s toolchain",
CORE.toolchain.value,
)
return 1
from esphome import platformio_api
logging.disable(logging.INFO)
@@ -1724,7 +1738,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
This command compiles the configuration and performs memory analysis.
Compilation is fast if sources haven't changed (just relinking).
"""
from esphome import platformio_api
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
@@ -1738,12 +1751,25 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
_LOGGER.info("Successfully compiled program.")
# Get idedata for analysis
idedata = platformio_api.get_idedata(config)
if idedata is None:
_LOGGER.error("Failed to get IDE data for memory analysis")
return 1
idedata = None
if CORE.using_toolchain_esp_idf:
from esphome.espidf import api
firmware_elf = Path(idedata.firmware_elf_path)
objdump_path = str(api.get_objdump_path())
readelf_path = str(api.get_readelf_path())
firmware_elf = api.get_elf_path()
else:
from esphome import platformio_api
idedata = platformio_api.get_idedata(config)
if idedata is None:
_LOGGER.error("Failed to get IDE data for memory analysis")
return 1
objdump_path = idedata.objdump_path
readelf_path = idedata.readelf_path
firmware_elf = Path(idedata.firmware_elf_path)
# Extract external components from config
external_components = detect_external_components(config)
@@ -1753,8 +1779,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
_LOGGER.info("Analyzing memory usage...")
analyzer = MemoryAnalyzerCLI(
str(firmware_elf),
idedata.objdump_path,
idedata.readelf_path,
objdump_path,
readelf_path,
external_components,
idedata=idedata,
)
@@ -1770,7 +1796,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
try:
ram_analyzer = RamStringsAnalyzer(
str(firmware_elf),
objdump_path=idedata.objdump_path,
objdump_path=objdump_path,
platform=CORE.target_platform,
)
ram_analyzer.analyze()
@@ -2015,6 +2041,17 @@ def parse_args(argv):
action="store_true",
default=False,
)
options_parser.add_argument(
"--toolchain",
type=Toolchain,
default=None,
choices=list(Toolchain),
metavar="{" + ",".join(t.value for t in Toolchain) + "}",
help=(
"Select toolchain for compiling. Overrides '<platform>.toolchain' in YAML. "
f"Default: {Toolchain.PLATFORMIO.value}."
),
)
parser = argparse.ArgumentParser(
description=f"ESPHome {const.__version__}", parents=[options_parser]
@@ -2059,11 +2096,6 @@ 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",
@@ -2171,11 +2203,6 @@ 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_run.add_argument(
"--ota-platform",
choices=[CONF_ESPHOME, CONF_WEB_SERVER],
@@ -2398,6 +2425,9 @@ def run_esphome(argv):
CORE.config_path = conf_path
CORE.dashboard = args.dashboard
if args.toolchain is not None:
# CLI toolchain wins over esp32.toolchain in YAML.
CORE.toolchain = args.toolchain
# Commands that don't need fresh external components: logs just connects
# to the device, and clean is about to delete the build directory.
@@ -2410,6 +2440,12 @@ def run_esphome(argv):
return 2
CORE.config = config
# Fallback for platforms whose validators didn't set the toolchain
# (only the esp32 component reads esp32.framework.toolchain). All
# other platforms only support PlatformIO today.
if CORE.toolchain is None:
CORE.toolchain = Toolchain.PLATFORMIO
if args.command not in POST_CONFIG_ACTIONS:
safe_print(f"Unknown command {args.command}")
return 1
+23 -1
View File
@@ -6,6 +6,7 @@ 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
from esphome.writer import update_storage_json
def get_available_components() -> list[str] | None:
@@ -54,7 +55,7 @@ def get_project_cmakelists() -> str:
idf_target = variant.lower().replace("-", "")
# Extract compile definitions from build flags (-DXXX -> XXX)
compile_defs = [flag for flag in CORE.build_flags if flag.startswith("-D")]
compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")]
extra_compile_options = "\n".join(
f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
for compile_def in compile_defs
@@ -64,6 +65,22 @@ def get_project_cmakelists() -> str:
# Auto-generated by ESPHome
cmake_minimum_required(VERSION 3.16)
# On Windows, Ninja can fail with:
# "CreateProcess: The parameter is incorrect (is the command line too long?)"
# when compiler/linker command lines exceed the OS length limit.
#
# The following settings force CMake/Ninja to use *response files* (@file.rsp)
# to pass long lists of includes, objects, and other arguments indirectly,
# avoiding command-line length limits and fixing the build failure.
#
# This is especially useful for large ESP-IDF / ESPHome projects with many
# source files or include directories.
set(CMAKE_C_USE_RESPONSE_FILE_FOR_INCLUDES 1)
set(CMAKE_CXX_USE_RESPONSE_FILE_FOR_INCLUDES 1)
set(CMAKE_C_USE_RESPONSE_FILE_FOR_OBJECTS 1)
set(CMAKE_CXX_USE_RESPONSE_FILE_FOR_OBJECTS 1)
set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1)
set(IDF_TARGET {idf_target})
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
@@ -124,6 +141,11 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
def write_project(minimal: bool = False) -> None:
"""Write ESP-IDF project files."""
# Refresh <data_dir>/storage/<name>.yaml.json so the dashboard's
# /info and /downloads endpoints can locate the build (they 404
# otherwise). This mirrors the PlatformIO build-gen path's call
# in build_gen/platformio.py:write_ini().
update_storage_json()
mkdir_p(CORE.build_path)
mkdir_p(CORE.relative_src_path())
+77 -14
View File
@@ -31,6 +31,7 @@ from esphome.const import (
CONF_SAFE_MODE,
CONF_SIZE,
CONF_SOURCE,
CONF_TOOLCHAIN,
CONF_TYPE,
CONF_VARIANT,
CONF_VERSION,
@@ -38,16 +39,17 @@ from esphome.const import (
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_NAME,
KEY_NATIVE_IDF,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
ThreadModel,
Toolchain,
__version__,
)
from esphome.core import CORE, HexInt
from esphome.core import CORE, HexInt, Library
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_component
import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
from esphome.types import ConfigType
@@ -465,6 +467,9 @@ def set_core_data(config):
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver
elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None:
if CORE.using_toolchain_esp_idf:
# Official ESP-IDF frameworks don't use extra
idf_ver = cv.Version(idf_ver.major, idf_ver.minor, idf_ver.patch)
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver
else:
raise cv.Invalid(
@@ -652,7 +657,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
return f"{ARDUINO_FRAMEWORK_PKG}@https://github.com/espressif/arduino-esp32/releases/download/{ver}/{filename}"
def _format_framework_espidf_version(
def _format_framework_pio_espidf_version(
ver: cv.Version, release: str | None = None
) -> str:
# format the given espidf (https://github.com/pioarduino/esp-idf/releases) version to
@@ -741,6 +746,7 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
"latest": cv.Version(5, 5, 4),
"dev": cv.Version(5, 5, 4),
}
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(
6, 0, 1
@@ -774,7 +780,7 @@ PLATFORM_VERSION_LOOKUP = {
}
def _check_versions(config):
def _check_pio_versions(config):
config = config.copy()
value = config[CONF_FRAMEWORK]
@@ -785,7 +791,7 @@ def _check_versions(config):
)
platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]
value[CONF_PLATFORM_VERSION] = _parse_platform_version(str(platform_lookup))
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
@@ -813,7 +819,7 @@ def _check_versions(config):
platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE,
_format_framework_espidf_version(version, value.get(CONF_RELEASE)),
_format_framework_pio_espidf_version(version, value.get(CONF_RELEASE)),
)
if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = f"pioarduino/framework-espidf@{value[CONF_SOURCE]}"
@@ -823,7 +829,7 @@ def _check_versions(config):
raise cv.Invalid(
"Framework version not recognized; please specify platform_version"
)
value[CONF_PLATFORM_VERSION] = _parse_platform_version(str(platform_lookup))
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if version != recommended_version:
_LOGGER.warning(
@@ -831,7 +837,7 @@ def _check_versions(config):
"If there are connectivity or build issues please remove the manual version."
)
if value[CONF_PLATFORM_VERSION] != _parse_platform_version(
if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP["recommended"])
):
_LOGGER.warning(
@@ -842,7 +848,38 @@ def _check_versions(config):
return config
def _parse_platform_version(value):
def _check_esp_idf_versions(config):
config = _check_pio_versions(config)
value = config[CONF_FRAMEWORK]
# Remove unwanted keys if present
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
value.pop(key, None)
# Official ESP-IDF frameworks don't use extra
version = cv.Version.parse(value[CONF_VERSION])
version = cv.Version(version.major, version.minor, version.patch)
value[CONF_VERSION] = str(version)
return config
def _validate_toolchain(value) -> Toolchain:
return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value))
def _check_versions(config):
# Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default.
if CORE.toolchain is None:
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO)
if CORE.using_toolchain_esp_idf:
return _check_esp_idf_versions(config)
return _check_pio_versions(config)
def _parse_pio_platform_version(value):
try:
ver = cv.Version.parse(cv.version_number(value))
release = f"{ver.major}.{ver.minor:02d}.{ver.patch:02d}"
@@ -1272,7 +1309,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
cv.Optional(CONF_RELEASE): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.string_strict,
cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version,
cv.Optional(CONF_PLATFORM_VERSION): _parse_pio_platform_version,
cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): {
cv.string_strict: cv.string_strict
},
@@ -1524,6 +1561,7 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True),
cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA,
cv.Optional(CONF_TOOLCHAIN): _validate_toolchain,
cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All(
cv.positive_time_period_seconds,
cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)),
@@ -1672,11 +1710,11 @@ async def to_code(config):
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)
# Check if using ESP-IDF toolchain
use_platformio = not CORE.using_toolchain_esp_idf
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
# but keep them when using ESP-IDF toolchain
for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"):
os.environ.pop(clean_var, None)
@@ -1716,6 +1754,8 @@ async def to_code(config):
)
else:
cg.add_build_flag("-Wno-error=format")
cg.add_build_flag("-Wno-error=missing-field-initializers")
cg.add_build_flag("-Wno-error=volatile")
cg.set_cpp_standard("gnu++20")
cg.add_build_flag("-DUSE_ESP32")
@@ -1792,7 +1832,7 @@ async def to_code(config):
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)],
[_format_framework_pio_espidf_version(idf_ver)],
)
# Use stub package to skip downloading precompiled libs
stubs_dir = CORE.relative_build_path("arduino_libs_stub")
@@ -2424,6 +2464,14 @@ def _write_sdkconfig():
clean_build(clear_pio_cache=False)
def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
dependency: dict[str, str] = {}
name, version, path = generate_idf_component(library)
dependency["override_path"] = str(path)
dependency["version"] = version
return name, dependency
def _write_idf_component_yml():
yml_path = CORE.relative_build_path("src/idf_component.yml")
dependencies: dict[str, dict] = {}
@@ -2465,6 +2513,21 @@ def _write_idf_component_yml():
if stub_path.exists():
rmtree(stub_path)
if CORE.using_toolchain_esp_idf:
add_idf_component(
name="espressif/arduino-esp32",
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
)
if CORE.using_toolchain_esp_idf:
# Try to convert PlatformIO library to ESP-IDF components
for name, library in CORE.platformio_libraries.items():
# Don't process arduino libraries
if name in ARDUINO_DISABLED_LIBRARIES:
continue
dependency_name, dependency = _platformio_library_to_dependency(library)
dependencies[dependency_name] = dependency
if CORE.data[KEY_ESP32][KEY_COMPONENTS]:
components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS]
for name, component in components.items():
+1 -2
View File
@@ -36,7 +36,6 @@ from esphome.const import (
CONF_VALUE,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_NATIVE_IDF,
Platform,
PlatformFramework,
)
@@ -705,7 +704,7 @@ def _filter_source_files() -> list[str]:
# and pioarduino doesn't have it builtin (IDF 5.4.2 to 5.x)
if eth_type != "JL1101":
excluded.append("esp_eth_phy_jl1101.c")
elif CORE.is_esp32 and not CORE.data.get(KEY_NATIVE_IDF, False):
elif CORE.is_esp32 and not CORE.using_toolchain_esp_idf:
from esphome.components.esp32 import idf_version
# pioarduino has JL1101 builtin on IDF 5.4.2-5.x; exclude custom driver
+1
View File
@@ -244,6 +244,7 @@ async def to_code(config):
# disable built in rgb support as it uses the new RMT drivers and will
# conflict with NeoPixelBus which uses the legacy drivers
cg.add_build_flag("-DESP32_ARDUINO_NO_RGB_BUILTIN")
cg.add_library("SPI", None)
cg.add_library("makuna/NeoPixelBus", "2.8.0")
else:
cg.add_library("makuna/NeoPixelBus", "2.7.3")
+1 -1
View File
@@ -142,7 +142,7 @@ async def _smpmgr_upload_connected(
with open(firmware, "rb") as file:
image = file.read()
upload_size = len(image)
progress = ProgressBar()
progress = ProgressBar("Uploading")
progress.update(0)
try:
async for offset in smp_client.upload(image):
+8 -1
View File
@@ -15,6 +15,13 @@ VALID_SUBSTITUTIONS_CHARACTERS = (
ARGUMENT_HELP_DEVICE = "Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses. Use 'OTA' for resolving from MQTT, DNS or mDNS and avoiding the interactive prompt."
class Toolchain(StrEnum):
"""Toolchain identifiers for ESPHome."""
PLATFORMIO = "platformio"
ESP_IDF = "esp-idf"
class Platform(StrEnum):
"""Platform identifiers for ESPHome."""
@@ -1036,6 +1043,7 @@ CONF_TO = "to"
CONF_TO_NTC_RESISTANCE = "to_ntc_resistance"
CONF_TO_NTC_TEMPERATURE = "to_ntc_temperature"
CONF_TOLERANCE = "tolerance"
CONF_TOOLCHAIN = "toolchain"
CONF_TOPIC = "topic"
CONF_TOPIC_PREFIX = "topic_prefix"
CONF_TOTAL = "total"
@@ -1393,7 +1401,6 @@ 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 = ""
+19 -6
View File
@@ -17,7 +17,6 @@ from esphome.const import (
CONF_WEB_SERVER,
CONF_WIFI,
KEY_CORE,
KEY_NATIVE_IDF,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_BK72XX,
@@ -28,6 +27,7 @@ from esphome.const import (
PLATFORM_NRF52,
PLATFORM_RP2040,
PLATFORM_RTL87XX,
Toolchain,
)
# pylint: disable=unused-import
@@ -618,6 +618,10 @@ class EsphomeCore:
# When True, skip network freshness checks for cached external files
# (e.g. for `esphome logs`, where remote downloads aren't needed)
self.skip_external_update: bool = False
# Toolchain used for building the configuration. None until resolved
# by CLI (--toolchain) or by `esphome.toolchain:` in YAML during
# preload_core_config; defaults to PLATFORMIO if neither sets it.
self.toolchain: Toolchain | None = None
def reset(self):
from esphome.pins import PIN_SCHEMA_REGISTRY
@@ -648,6 +652,7 @@ class EsphomeCore:
self.address_cache = None
self._config_hash = None
self.skip_external_update = False
self.toolchain = None
PIN_SCHEMA_REGISTRY.reset()
@contextmanager
@@ -772,8 +777,8 @@ 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):
# Check if using ESP-IDF toolchain
if self.using_toolchain_esp_idf:
return self.relative_build_path("build", f"{self.name}.bin")
if self.is_libretiny:
return self.relative_pioenvs_path(self.name, "firmware.uf2")
@@ -781,10 +786,10 @@ class EsphomeCore:
@property
def partition_table_bin(self) -> Path:
# Native ESP-IDF (--native-idf): the partition table image is emitted under
# Native ESP-IDF (--toolchain esp-idf): the partition table image is emitted under
# build/partition_table/partition-table.bin alongside firmware.bin. PlatformIO writes the
# equivalent file as partitions.bin in the env-specific .pioenvs directory.
if self.data.get(KEY_NATIVE_IDF):
if self.using_toolchain_esp_idf:
return self.relative_build_path(
"build", "partition_table", "partition-table.bin"
)
@@ -792,7 +797,7 @@ class EsphomeCore:
@property
def bootloader_bin(self) -> Path:
if self.data.get(KEY_NATIVE_IDF):
if self.using_toolchain_esp_idf:
return self.relative_build_path("build", "bootloader", "bootloader.bin")
return self.relative_pioenvs_path(self.name, "bootloader.bin")
@@ -853,6 +858,14 @@ class EsphomeCore:
)
return self.target_framework == "esp-idf"
@property
def using_toolchain_esp_idf(self):
return self.toolchain == Toolchain.ESP_IDF
@property
def using_toolchain_platformio(self):
return self.toolchain == Toolchain.PLATFORMIO
@property
def using_zephyr(self):
return self.target_framework == "zephyr"
View File
+499
View File
@@ -0,0 +1,499 @@
"""ESP-IDF direct build API for ESPHome."""
from dataclasses import dataclass, field
import json
import logging
import os
from pathlib import Path
import re
import shutil
import subprocess
from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION
from esphome.core import CORE, EsphomeError
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
_LOGGER = logging.getLogger(__name__)
DOMAIN = "espidf_api"
@dataclass
class _CacheData:
paths: dict[str, tuple] = field(default_factory=dict)
env: dict[str, dict[str, str]] = field(default_factory=dict)
cmake_output: dict[Path, str] = field(default_factory=dict)
cmake_tools: dict[Path, dict[str, Path]] = field(default_factory=dict)
def _cache() -> _CacheData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = _CacheData()
return CORE.data[DOMAIN]
def _get_core_framework_version():
return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION])
def _get_esphome_esp_idf_paths(
version: str | None = None,
) -> tuple[os.PathLike, os.PathLike]:
version = version or _get_core_framework_version()
paths = _cache().paths
if version not in paths:
paths[version] = check_esp_idf_install(version)
return paths[version]
def _get_idf_path(version: str | None = None) -> Path | None:
"""Get IDF_PATH from environment or common locations."""
# Use provided IDF framework if available
if "IDF_PATH" in os.environ:
return Path(os.environ["IDF_PATH"])
return Path(_get_esphome_esp_idf_paths(version)[0])
def _get_idf_env(version: str | None = None) -> dict[str, str]:
"""Get environment variables needed for ESP-IDF build."""
version = version or _get_core_framework_version()
env_cache = _cache().env
if version not in env_cache:
env_cache[version] = os.environ.copy()
# Use provided IDF framework if available
if "IDF_PATH" not in os.environ:
env_cache[version] |= get_framework_env(
*_get_esphome_esp_idf_paths(version)
)
return env_cache[version]
def _get_cmake_output(build_dir) -> str:
cmake_output_cache = _cache().cmake_output
if build_dir not in cmake_output_cache:
cmd = ["cmake", "-LA", "-N", "."]
env = _get_idf_env()
result = subprocess.run(
cmd,
cwd=build_dir,
env=env,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"CMake failed: {result.stderr}")
cmake_output_cache[build_dir] = result.stdout
return cmake_output_cache[build_dir]
def _get_cmake_tool_path(var_name: str) -> Path:
build_dir = CORE.relative_build_path("build")
cmake_output = _get_cmake_output(build_dir)
cmake_tools_cache = _cache().cmake_tools
if build_dir not in cmake_tools_cache:
cmake_tools_cache[build_dir] = {}
if var_name not in cmake_tools_cache[build_dir]:
pattern = rf"^{var_name}:FILEPATH=(.+)$"
match = re.search(pattern, cmake_output, re.MULTILINE)
if not match:
raise RuntimeError(f"{var_name} not found in CMake output")
path = match.group(1).strip()
cmake_tools_cache[build_dir][var_name] = Path(path)
return cmake_tools_cache[build_dir][var_name]
def _get_idf_tool(name: str) -> str:
"""Return the path to an executable from the ESP-IDF environment PATH or raise if not found."""
env = _get_idf_env()
executable = shutil.which(name, path=env.get("PATH", None))
if executable is None:
raise EsphomeError(
f"{name} executable not found in ESP-IDF environment. "
"Check that the IDF environment is correctly set up."
)
return executable
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()
python_executable = _get_idf_tool("python")
idf_py = idf_path / "tools" / "idf.py"
# Dispatch idf.py through esphome.espidf.runner, which wraps
# sys.stdout/sys.stderr so ``isatty()`` reports True. This keeps CMake,
# Ninja, and idf.py's own progress-bar code emitting TTY-format output
# (``\r`` cursor moves, ANSI colors, fancy progress bars) even when our
# real stdout is a pipe — e.g. when esphome is running under the Home
# Assistant dashboard add-on. The runner is a plain script (not a
# ``python -m`` module) because IDF's Python venv does not have the
# esphome package installed.
runner_py = Path(__file__).parent / "runner.py"
cmd = [python_executable, str(runner_py), 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 _get_sdkconfig_args() -> list[str]:
"""Get cmake -D flags for the sdkconfig file, if it exists."""
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
if sdkconfig_path.is_file():
return ["-D", f"SDKCONFIG={sdkconfig_path}"]
return []
def run_reconfigure() -> int:
"""Run cmake reconfigure only (no build)."""
return run_idf_py(*_get_sdkconfig_args(), "reconfigure")
def has_outdated_files():
"""Check if the build configuration is stale.
Returns True if required build files are missing or if configuration inputs
are newer than the generated CMake/Ninja build artifacts.
"""
cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt")
cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt")
cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt")
build_config_path = CORE.relative_build_path("build/config")
sdkconfig_internal_path = CORE.relative_build_path(
f"sdkconfig.{CORE.name}.esphomeinternal"
)
dependency_lock_path = CORE.relative_build_path("dependencies.lock")
build_ninja_path = CORE.relative_build_path("build/build.ninja")
if not os.path.isdir(build_config_path) or not os.listdir(build_config_path):
return True
if not os.path.isfile(cmakecache_txt_path):
return True
if not os.path.isfile(build_ninja_path):
return True
if os.path.isfile(dependency_lock_path) and os.path.getmtime(
dependency_lock_path
) > os.path.getmtime(build_ninja_path):
return True
cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path)
return any(
os.path.getmtime(f) > cmakecache_txt_mtime
for f in [
_get_idf_path(),
cmakelists_txt_build_path,
cmakelists_txt_src_path,
sdkconfig_internal_path,
build_config_path,
]
if f and os.path.exists(f)
)
def need_reconfigure() -> bool:
from esphome.build_gen.espidf import has_discovered_components
# We need to reconfigure either if the files are outdated or if there is no component discovered
return has_outdated_files() or not has_discovered_components()
def _patch_memory_segments():
"""Patch memory.ld to expand IRAM/DRAM for testing mode.
Mirrors the PlatformIO iram_fix.py.script logic for native IDF builds.
Must be called after cmake configure (which generates memory.ld) and
before the build/link step.
"""
# Same sizes as iram_fix.py.script
testing_iram_size = 0x200000 # 2MB
testing_dram_size = 0x200000 # 2MB
memory_ld = CORE.relative_build_path(
"build", "esp-idf", "esp_system", "ld", "memory.ld"
)
if not memory_ld.is_file():
_LOGGER.warning("Could not find linker script at %s", memory_ld)
return
content = memory_ld.read_text()
patches = []
def _patch_segment(text, segment_name, new_size):
pattern = rf"({re.escape(segment_name)}\s*\([^)]*\)\s*:\s*org\s*=\s*.+?,\s*len\s*=\s*)(\S+[^\n]*)"
if match := re.search(pattern, text, re.DOTALL):
replacement = f"{match.group(1)}{new_size:#x}"
new_text = text[: match.start()] + replacement + text[match.end() :]
if new_text != text:
return new_text, True
return text, False
content, patched = _patch_segment(content, "iram0_0_seg", testing_iram_size)
if patched:
patches.append(f"IRAM={testing_iram_size:#x}")
content, patched = _patch_segment(content, "dram0_0_seg", testing_dram_size)
if patched:
patches.append(f"DRAM={testing_dram_size:#x}")
if patches:
memory_ld.write_text(content)
_LOGGER.info("Patched %s in %s for testing mode", ", ".join(patches), memory_ld)
else:
_LOGGER.warning("Could not patch memory segments in %s", memory_ld)
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 write_project
# Check if we need to do discovery phase
if need_reconfigure():
_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)
if CORE.testing_mode:
# Reconfigure again so cmake is up to date with the full component
# list. This ensures idf.py build won't re-run cmake, which would
# regenerate memory.ld and wipe the DRAM/IRAM patches applied below.
rc = run_reconfigure()
if rc != 0:
_LOGGER.error("Reconfigure with discovered components failed")
return rc
# In testing mode, generate the linker script first, patch DRAM/IRAM sizes,
# then build. memory.ld is regenerated by ninja during the build phase,
# so we must patch after it's generated but before linking (same timing
# as iram_fix.py.script's AddPreAction hook in the PlatformIO path).
if CORE.testing_mode:
memory_ld = CORE.relative_build_path(
"build", "esp-idf", "esp_system", "ld", "memory.ld"
)
build_dir = CORE.relative_build_path("build")
# Build just the memory.ld target - ninja needs the path relative to build dir
memory_ld_target = os.path.relpath(str(memory_ld), str(build_dir))
env = _get_idf_env()
ninja_executable = _get_idf_tool("ninja")
result = subprocess.run(
[ninja_executable, "-C", str(build_dir), memory_ld_target],
env=env,
check=False,
)
if result.returncode != 0:
_LOGGER.error("Failed to generate linker script")
return result.returncode
_patch_memory_segments()
# Build
args = []
if verbose:
args.append("-v")
args.extend(_get_sdkconfig_args())
args.append("build")
return run_idf_py(*args)
def get_firmware_path() -> Path:
"""Get the path to the compiled firmware binary.
This is the file idf.py writes directly (named after the project),
not the copy used for OTA/factory downloads below.
"""
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).
Uses the PlatformIO ``firmware.factory.bin`` naming convention so
the dashboard's download handler — which requests files by name
relative to ``firmware_bin_path.parent`` finds it. Without this,
the native IDF path produced ``<name>.factory.bin`` and the
dashboard returned 500 trying to locate ``firmware.factory.bin``.
"""
build_dir = CORE.relative_build_path("build")
return build_dir / "firmware.factory.bin"
def get_ota_firmware_path() -> Path:
"""Get the path to the OTA firmware binary.
Uses the PlatformIO ``firmware.ota.bin`` naming convention for the
same dashboard-compatibility reason as ``get_factory_firmware_path``.
"""
build_dir = CORE.relative_build_path("build")
return build_dir / "firmware.ota.bin"
def get_elf_path() -> Path:
"""Get the path to the firmware ELF file.
idf.py writes ``<build>/<name>.elf`` directly; this returns the
``<build>/firmware.elf`` copy created by ``create_elf_copy`` so
the dashboard's "download ELF" link can find it under the
PlatformIO-convention name.
"""
build_dir = CORE.relative_build_path("build")
return build_dir / "firmware.elf"
def get_objdump_path() -> Path:
return _get_cmake_tool_path("CMAKE_OBJDUMP")
def get_readelf_path() -> Path:
return _get_cmake_tool_path("CMAKE_READELF")
def get_addr2line_path() -> Path:
return _get_cmake_tool_path("CMAKE_ADDR2LINE")
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")
env = _get_idf_env()
python_executable = _get_idf_tool("python")
cmd = [
python_executable,
"-m",
"esptool",
"--chip",
chip,
"merge_bin",
"--flash_size",
flash_size,
"--output",
str(output_path),
] + sections
_LOGGER.info("Creating factory.bin...")
result = subprocess.run(cmd, env=env, 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 firmware.ota.bin for ESPHome OTA compatibility."""
firmware_path = get_firmware_path()
ota_path = get_ota_firmware_path()
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
def create_elf_copy() -> bool:
"""Copy the ELF binary to firmware.elf for dashboard compatibility.
idf.py writes the ELF at ``<build>/<name>.elf``; the dashboard's
"download ELF" link requests the literal filename ``firmware.elf``
(PlatformIO convention), so copy it to that name.
"""
build_dir = CORE.relative_build_path("build")
src_elf = build_dir / f"{CORE.name}.elf"
dst_elf = get_elf_path()
if not src_elf.is_file():
_LOGGER.warning("ELF not found: %s", src_elf)
return False
shutil.copy(src_elf, dst_elf)
_LOGGER.info("Created: %s", dst_elf)
return True
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
"""Print JSON ``{paths_to_export, export_vars}`` for ESP-IDF tools.
Run via ``python <this file> <idf_framework_root>``. PYTHONPATH must include
``<idf_framework_root>/tools`` so ``idf_tools`` is importable. Exits with
status 1 and prints ``Missing ESP-IDF tools: ...`` on stderr if any tool is
not installed.
"""
# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only
import json
import os
import sys
from types import SimpleNamespace
from idf_tools import (
TOOLS_FILE,
IDFEnv,
IDFTool,
filter_tools_info,
g,
load_tools_info,
process_tool,
)
g.idf_path = sys.argv[1]
g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH")
g.tools_json = os.path.join(g.idf_path, TOOLS_FILE)
tools_info = filter_tools_info(IDFEnv.get_idf_env(), load_tools_info())
args = SimpleNamespace(prefer_system=False)
paths_to_export: list[str] = []
export_vars: dict[str, str] = {}
missing_tools: list[str] = []
for name, tool in tools_info.items():
if tool.get_install_type() == IDFTool.INSTALL_NEVER:
continue
tool_paths, tool_vars, found = process_tool(
tool, name, args, "install_cmd", "prefer_system_hint"
)
if not found:
missing_tools.append(name)
paths_to_export += tool_paths
export_vars |= tool_vars
if missing_tools:
print("Missing ESP-IDF tools: " + ", ".join(missing_tools), file=sys.stderr)
raise SystemExit(1)
print(json.dumps({"paths_to_export": paths_to_export, "export_vars": export_vars}))
+14
View File
@@ -0,0 +1,14 @@
"""Print the ESP-IDF version of a given framework root.
Run via ``python <this file> <idf_framework_root>``. PYTHONPATH must include
``<idf_framework_root>/tools`` so ``idf_tools`` is importable.
"""
# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only
import sys
from idf_tools import g, get_idf_version
g.idf_path = sys.argv[1]
print(get_idf_version())
+223
View File
@@ -0,0 +1,223 @@
r"""Subprocess entry point for running ``idf.py`` with stdio wrapping.
Invoked as ``python runner.py <script_path> [script args...]``.
Wraps ``sys.stdout`` and ``sys.stderr`` with a ``_FilteringTTYStream``
shim so that:
1. ``isatty()`` unconditionally returns True. CMake, Ninja, and idf.py's
own progress-bar code all check ``stream.isatty()`` to decide between
TTY-format output (``\\r`` cursor moves, ANSI colors, fancy progress
bars) and a plain fallback. With the wrapper in place they always
emit TTY format, even when our real stdout is a pipe to the parent
process (e.g. running under the Home Assistant dashboard add-on).
Downstream consumers local terminals and the HA dashboard log
viewer render the TTY control sequences correctly.
2. ``FILTER_IDF_LINES`` is applied inside the shim's ``write()`` so
noisy idf.py output is dropped before it leaves this subprocess.
Filtering is skipped when ``-v`` / ``--verbose`` appears in argv so
verbose mode still shows everything.
ESP-IDF runs under its own Python virtual environment which does not
have the ``esphome`` package installed, so the runner is intentionally
self-contained: no imports from ``esphome`` at all. The line-filtering
wrapper is inlined below rather than imported from
``esphome.util.RedirectText`` for that reason.
"""
import sys
# Regex patterns matched against each line of idf.py / CMake / Ninja
# output. Lines that match are dropped before reaching the parent
# process. Patterns are anchored at the start of the line (the shim
# uses ``re.match``). Disabled when the user passes ``-v`` /
# ``--verbose`` to ``esphome compile``.
FILTER_IDF_LINES: list[str] = [
# idf.py's "how to flash" block at the end of a successful build.
# ESPHome handles flashing itself, so these instructions just clutter
# the output.
r"Project build complete\.",
r" idf\.py ",
r" python -m esptool ",
r"or$",
r"or from the ",
# CMake dumps the full list of IDF component paths on one giant line.
# It's purely informational and bloats the log.
r"-- Component paths:",
# CMake lists every linker script it adds (dozens of lines) and the
# complete flat list of IDF components on one giant line. Neither
# has diagnostic value for end users.
r"-- Adding linker script ",
r"-- Components:",
# IDF component manager notices: emitted on first build (no lock),
# once per stubbed dependency, plus the final "Processing N
# dependencies" enumeration. Patterns allow a leading run of dots
# because the component manager prints progress dots on the same
# line, so a NOTICE often arrives prefixed with ".NOTICE:" or
# "...........NOTICE:".
r"\.*NOTICE: ",
]
def main() -> int:
# ---- sys.path fix-up ---------------------------------------------------
#
# When Python runs this file as ``python runner.py``, it prepends the
# script's directory — ``<site-packages>/esphome/espidf/`` — to
# ``sys.path[0]``. That directory is part of the esphome package whose
# sibling ``types.py`` (in ``esphome/``) collides with stdlib ``types``.
# Any subsequent import that transitively touches ``types`` (``runpy``,
# ``pathlib``, ``functools``, ``typing``, ...) could resolve the wrong
# module. Drop the entry pre-emptively. ``sys`` is a built-in so
# importing it at module level earlier did not trigger the shadow.
if sys.path and sys.path[0]:
sys.path.pop(0)
# ---- end sys.path fix-up -----------------------------------------------
import os
import re
import runpy
# Patch ``os.get_terminal_size`` to return a fallback size instead
# of raising ``OSError`` when the underlying fd isn't a real
# terminal.
#
# idf.py's ``fit_text_in_terminal`` (in ``idf_py_actions/tools.py``)
# unconditionally calls ``os.get_terminal_size()`` to format ninja
# progress lines. When that raises ``[Errno 25] Inappropriate
# ioctl for device`` on our pipe-backed stdout, idf.py catches the
# exception as ``EnvironmentError`` and silently exits its stdout
# reader coroutine — dropping all ninja build output from that
# point on. Returning a valid value keeps the coroutine alive so
# progress and error lines continue to flow through to the parent
# process.
#
# Honour the ``COLUMNS`` / ``LINES`` env vars if the caller set
# them explicitly. Otherwise fall back to ``(0, 0)``, which
# ``fit_text_in_terminal`` treats as "unknown width, don't
# truncate" (see the ``if not terminal_width: return out`` guard).
# Downstream log viewers (local terminals, the HA dashboard) wrap
# or scroll long lines themselves, so we'd rather emit the full
# file path than have idf.py elide its middle.
_orig_get_terminal_size = os.get_terminal_size
def _get_terminal_size_fallback(fd: int = 1) -> os.terminal_size:
try:
return _orig_get_terminal_size(fd)
except OSError:
try:
columns = int(os.environ.get("COLUMNS", "0"))
except ValueError:
columns = 0
try:
lines = int(os.environ.get("LINES", "0"))
except ValueError:
lines = 0
return os.terminal_size((columns, lines))
os.get_terminal_size = _get_terminal_size_fallback # type: ignore[assignment]
# Strip ANSI escape sequences before comparing a line against the filter
# patterns, so colorized lines still match plain-text patterns.
ansi_escape = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]")
class _FilteringTTYStream:
r"""Minimal stdout/stderr wrapper.
* ``isatty()`` unconditionally returns True, tricking downstream
code into emitting TTY-format output.
* Input is split on ``\\n`` / ``\\r`` via
``str.splitlines(keepends=True)`` and any complete line whose
ANSI-stripped, right-stripped form matches one of
``filter_lines`` is dropped.
* Incomplete trailing chunks are held in a buffer until a
terminator arrives.
Mirrors the matching semantics of ``esphome.util.RedirectText``
so filter patterns behave identically in both the PlatformIO
and IDF runner paths.
"""
def __init__(self, stream, filter_lines: list[str] | None) -> None:
self._stream = stream
if filter_lines:
combined = r"|".join(r"(?:" + p + r")" for p in filter_lines)
self._filter_pattern: re.Pattern[str] | None = re.compile(combined)
else:
self._filter_pattern = None
self._line_buffer = ""
def __getattr__(self, name: str):
return getattr(self._stream, name)
def isatty(self) -> bool:
return True
def flush(self) -> None:
self._stream.flush()
def write(self, data) -> int:
# Text streams normally hand us ``str``; decode in case
# somebody writes bytes directly.
if not isinstance(data, str):
data = data.decode(errors="replace")
if self._filter_pattern is None:
self._stream.write(data)
return len(data)
self._line_buffer += data
for line in self._line_buffer.splitlines(keepends=True):
if "\n" not in line and "\r" not in line:
# Incomplete — hold until we see a terminator.
self._line_buffer = line
break
self._line_buffer = ""
stripped = ansi_escape.sub("", line).rstrip()
if self._filter_pattern.match(stripped) is not None:
continue
self._stream.write(line)
return len(data)
if len(sys.argv) < 2:
print(
"usage: runner.py <script_path> [args...]",
file=sys.stderr,
)
return 2
script_path = sys.argv[1]
# Mirror the platformio_runner behaviour: verbose mode disables the
# line filter so all output reaches the user.
is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:])
filter_lines = None if is_verbose else FILTER_IDF_LINES or None
sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment]
sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment]
# Shift argv so the target script sees its own path as argv[0] and
# its own arguments starting at argv[1]. runpy.run_path does not
# modify sys.argv itself.
sys.argv = [script_path] + sys.argv[2:]
# Emulate Python's default behaviour of prepending the script's
# directory to sys.path[0] when running ``python script.py``.
# runpy.run_path does not do this automatically, but idf.py relies
# on it to import its sibling modules (python_version_checker,
# idf_py_actions, ...).
script_dir = os.path.dirname(os.path.abspath(script_path))
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
# If idf.py calls sys.exit(), SystemExit propagates out of run_path
# and carries the exit code back to our caller. For normal returns,
# fall through and exit with 0.
runpy.run_path(script_path, run_name="__main__")
return 0
if __name__ == "__main__":
sys.exit(main())
-274
View File
@@ -1,274 +0,0 @@
"""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.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 has_outdated_files():
"""Check if the build configuration is stale.
Returns True if required build files are missing or if configuration inputs
are newer than the generated CMake/Ninja build artifacts.
"""
cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt")
cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt")
cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt")
build_config_path = CORE.relative_build_path("build/config")
sdkconfig_internal_path = CORE.relative_build_path(
f"sdkconfig.{CORE.name}.esphomeinternal"
)
dependency_lock_path = CORE.relative_build_path("dependencies.lock")
build_ninja_path = CORE.relative_build_path("build/build.ninja")
if not os.path.isdir(build_config_path) or not os.listdir(build_config_path):
return True
if not os.path.isfile(cmakecache_txt_path):
return True
if not os.path.isfile(build_ninja_path):
return True
if os.path.isfile(dependency_lock_path) and os.path.getmtime(
dependency_lock_path
) > os.path.getmtime(build_ninja_path):
return True
cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path)
return any(
os.path.getmtime(f) > cmakecache_txt_mtime
for f in [
_get_idf_path(),
cmakelists_txt_build_path,
cmakelists_txt_src_path,
sdkconfig_internal_path,
build_config_path,
]
if f and os.path.exists(f)
)
def need_reconfigure() -> bool:
from esphome.build_gen.espidf import has_discovered_components
# We need to reconfigure either if the files are outdated or if there is no component discovered
return has_outdated_files() or not has_discovered_components()
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 write_project
# Check if we need to do discovery phase
if need_reconfigure():
_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 = []
if verbose:
args.append("-v")
args.append("build")
# 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
+1 -1
View File
@@ -441,7 +441,7 @@ def perform_ota(
start_time = time.perf_counter()
offset = 0
progress = ProgressBar()
progress = ProgressBar("Uploading")
while True:
chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE]
if not chunk:
+10 -3
View File
@@ -11,7 +11,7 @@ import shutil
import stat
import sys
import tempfile
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, TextIO
from urllib.parse import urlparse
from esphome.const import __version__ as ESPHOME_VERSION
@@ -617,10 +617,15 @@ def sanitize(value):
class ProgressBar:
"""A simple terminal progress bar for upload operations."""
def __init__(self) -> None:
def __init__(self, header: str, stream: TextIO | None = None) -> None:
self.header = header
self.stream = stream or sys.stderr
self.last_progress: int | None = None
self.enabled = hasattr(self.stream, "isatty") and self.stream.isatty()
def update(self, progress: float) -> None:
if not self.enabled:
return
bar_length = 60
status = ""
if progress >= 1:
@@ -631,11 +636,13 @@ class ProgressBar:
return
self.last_progress = new_progress
block = int(round(bar_length * progress))
text = f"\rUploading: [{'=' * block + ' ' * (bar_length - block)}] {new_progress}% {status}"
text = f"\r{self.header}: [{'=' * block + ' ' * (bar_length - block)}] {new_progress}% {status}"
sys.stderr.write(text)
sys.stderr.flush()
def done(self) -> None:
if not self.enabled:
return
sys.stderr.write("\n")
sys.stderr.flush()
+1 -1
View File
@@ -60,7 +60,7 @@ class _MultipartStreamer:
self._idx = 0
self._total = len(prefix) + file_size + len(suffix)
self._sent = 0
self.progress = ProgressBar()
self.progress = ProgressBar("Uploading")
def __len__(self) -> int:
return self._total
+1 -1
View File
@@ -562,7 +562,7 @@ def lint_constants_usage():
# Maximum allowed CONF_ constants in esphome/const.py.
# This file is frozen — new constants go in esphome/components/const/__init__.py.
# Decrease this number when constants are moved out of const.py.
CONST_PY_MAX_CONF = 1011
CONST_PY_MAX_CONF = 1012
@lint_content_check(include=["esphome/const.py"])
+41 -10
View File
@@ -175,7 +175,7 @@ def group_components_by_platform(
}
def format_github_summary(test_results: list[TestResult]) -> str:
def format_github_summary(test_results: list[TestResult], toolchain=None) -> str:
"""Format test results as GitHub Actions job summary markdown.
Args:
@@ -225,11 +225,12 @@ def format_github_summary(test_results: list[TestResult]) -> str:
lines.append("```bash\n")
# Generate one command per platform and test type
extra_arguments = f" --toolchain {toolchain}" if toolchain else ""
platform_components = group_components_by_platform(failed_results)
for platform, test_type in sorted(platform_components.keys()):
components_csv = ",".join(platform_components[(platform, test_type)])
lines.append(
f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}\n"
f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}{extra_arguments}\n"
)
lines.append("```\n")
@@ -274,13 +275,15 @@ def format_github_summary(test_results: list[TestResult]) -> str:
return "".join(lines)
def write_github_summary(test_results: list[TestResult]) -> None:
def write_github_summary(
test_results: list[TestResult], toolchain: str | None = None
) -> None:
"""Write GitHub Actions job summary with test results and timing.
Args:
test_results: List of all test results
"""
summary_content = format_github_summary(test_results)
summary_content = format_github_summary(test_results, toolchain)
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f:
f.write(summary_content)
@@ -308,6 +311,7 @@ def run_esphome_test(
esphome_command: str,
continue_on_fail: bool,
use_testing_mode: bool = False,
toolchain: str | None = None,
) -> TestResult:
"""Run esphome test for a single component.
@@ -367,8 +371,14 @@ def run_esphome_test(
]
)
# Add command and config file
cmd.extend([esphome_command, str(output_file)])
if toolchain:
cmd.extend(["--toolchain", toolchain])
# Add command
cmd.append(esphome_command)
# Add config file
cmd.append(str(output_file))
# Build command string for display/logging
cmd_str = " ".join(cmd)
@@ -432,6 +442,7 @@ def run_grouped_test(
tests_dir: Path,
esphome_command: str,
continue_on_fail: bool,
toolchain: str | None = None,
) -> TestResult:
"""Run esphome test for a group of components with shared bus configs.
@@ -510,10 +521,16 @@ def run_grouped_test(
"-s",
"target_platform",
platform,
esphome_command,
str(output_file),
]
if toolchain:
cmd.extend(["--toolchain", toolchain])
# Add command
cmd.append(esphome_command)
cmd.append(str(output_file))
# Build command string for display/logging
cmd_str = " ".join(cmd)
@@ -576,6 +593,7 @@ def run_grouped_component_tests(
esphome_command: str,
continue_on_fail: bool,
additional_isolated: set[str] | None = None,
toolchain: str | None = None,
) -> tuple[set[tuple[str, str]], list[TestResult]]:
"""Run grouped component tests.
@@ -879,6 +897,7 @@ def run_grouped_component_tests(
tests_dir=tests_dir,
esphome_command=esphome_command,
continue_on_fail=continue_on_fail,
toolchain=toolchain,
)
# Mark all components as tested
@@ -902,6 +921,7 @@ def run_individual_component_test(
continue_on_fail: bool,
tested_components: set[tuple[str, str]],
test_results: list[TestResult],
toolchain: str | None = None,
) -> None:
"""Run an individual component test if not already tested in a group.
@@ -930,6 +950,7 @@ def run_individual_component_test(
build_dir=build_dir,
esphome_command=esphome_command,
continue_on_fail=continue_on_fail,
toolchain=toolchain,
)
test_results.append(test_result)
@@ -942,6 +963,7 @@ def test_components(
enable_grouping: bool = True,
isolated_components: set[str] | None = None,
base_only: bool = False,
toolchain: str | None = None,
) -> int:
"""Test components with optional intelligent grouping.
@@ -1018,6 +1040,7 @@ def test_components(
esphome_command=esphome_command,
continue_on_fail=continue_on_fail,
additional_isolated=isolated_components,
toolchain=toolchain,
)
test_results.extend(grouped_results)
@@ -1046,6 +1069,7 @@ def test_components(
continue_on_fail=continue_on_fail,
tested_components=tested_components,
test_results=test_results,
toolchain=toolchain,
)
else:
# Platform-specific test
@@ -1078,6 +1102,7 @@ def test_components(
continue_on_fail=continue_on_fail,
tested_components=tested_components,
test_results=test_results,
toolchain=toolchain,
)
# Separate results into passed and failed
@@ -1098,17 +1123,18 @@ def test_components(
print("\n" + "=" * 80)
print("Commands to reproduce failures (copy-paste to reproduce locally):")
print("=" * 80)
extra_arguments = f" --toolchain {toolchain}" if toolchain else ""
platform_components = group_components_by_platform(failed_results)
for platform, test_type in sorted(platform_components.keys()):
components_csv = ",".join(platform_components[(platform, test_type)])
print(
f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}"
f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}{extra_arguments}"
)
print()
# Write GitHub Actions job summary if in CI
if os.environ.get("GITHUB_STEP_SUMMARY"):
write_github_summary(test_results)
write_github_summary(test_results, toolchain=toolchain)
if failed_results:
return 1
@@ -1161,6 +1187,10 @@ def main() -> int:
action="store_true",
help="Only test base test files (test.*.yaml), not variant files (test-*.yaml)",
)
parser.add_argument(
"--toolchain",
help="Select toolchain for compiling.",
)
args = parser.parse_args()
@@ -1180,6 +1210,7 @@ def main() -> int:
enable_grouping=not args.no_grouping,
isolated_components=isolated_components,
base_only=args.base_only,
toolchain=args.toolchain,
)
+2 -2
View File
@@ -16,8 +16,8 @@ from esphome.const import (
CONF_ESPHOME,
CONF_IGNORE_PIN_VALIDATION_ERROR,
CONF_NUMBER,
KEY_NATIVE_IDF,
PlatformFramework,
Toolchain,
)
from esphome.core import CORE
from tests.component_tests.types import SetCoreConfigCallable
@@ -266,7 +266,7 @@ def test_native_idf_enables_reproducible_build(
CORE.config_path = component_config_path("reproducible_build.yaml")
CORE.config = read_config({})
CORE.data[KEY_NATIVE_IDF] = True
CORE.toolchain = Toolchain.ESP_IDF
generate_cpp_contents(CORE.config)
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
+2 -2
View File
@@ -855,7 +855,7 @@ class TestEsphomeCore:
def test_bootloader_bin__native_idf(self, target):
"""Native ESP-IDF builds emit the bootloader under build/bootloader/bootloader.bin."""
target.data[const.KEY_NATIVE_IDF] = True
target.toolchain = const.Toolchain.ESP_IDF
assert target.bootloader_bin == Path(
"foo/build/build/bootloader/bootloader.bin"
@@ -864,7 +864,7 @@ class TestEsphomeCore:
def test_bootloader_bin__platformio(self, target):
"""For PlatformIO builds bootloader.bin lives in the env-specific .pioenvs directory."""
target.name = "test-device"
target.data[const.KEY_NATIVE_IDF] = False
target.toolchain = const.Toolchain.PLATFORMIO
assert target.bootloader_bin == Path(
"foo/build/.pioenvs/test-device/bootloader.bin"
+357
View File
@@ -0,0 +1,357 @@
import json
import os
from unittest.mock import MagicMock
import pytest
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Framework,
Platform,
)
from esphome.core import CORE, Library
import esphome.espidf.component
from esphome.espidf.component import (
GitSource,
IDFComponent,
InvalidIDFComponent,
URLSource,
_check_library_data,
_collect_filtered_files,
_convert_library_to_component,
_detect_requires,
_parse_library_json,
_parse_library_properties,
_process_dependencies,
_split_list_by_condition,
generate_cmakelists_txt,
generate_idf_component_yml,
)
@pytest.fixture(name="tmp_component")
def fixture_tmp_component(tmp_path):
c = IDFComponent("owner/name", "1.0.0", source=MagicMock())
c.path = tmp_path
return c
@pytest.fixture(name="esp32_idf_core")
def fixture_esp32_idf_core():
CORE.data[KEY_CORE] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = str(Platform.ESP32)
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ESP_IDF)
def test_idf_component_str():
c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com"))
assert str(c) == "foo/bar@1.0=http://dummy.com"
def test_idf_component_sanitized_name():
c = IDFComponent("foo/bar bar-bar", "1.0", source=URLSource("http://dummy.com"))
assert c.get_sanitized_name() == "foo/bar_bar-bar"
def test_idf_component_require_name():
c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com"))
assert c.get_require_name() == "foo__bar"
def test_collect_filtered_files_basic(tmp_path):
f1 = tmp_path / "a.c"
f2 = tmp_path / "b" / "b.cpp"
f1.write_text("int a;")
f2.parent.mkdir(parents=True)
f2.write_text("int b;")
result = _collect_filtered_files(tmp_path, ["+<*>"])
assert str(f1) in result
assert str(f2) in result
def test_collect_filtered_files_exclude(tmp_path):
f1 = tmp_path / "a.c"
f2 = tmp_path / "b.cpp"
f1.write_text("int a;")
f2.write_text("int b;")
result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"])
assert str(f1) in result
assert str(f2) not in result
def test_detect_requires(tmp_path):
f = tmp_path / "main.c"
f.write_text('#include "mbedtls/foo.h"')
result = _detect_requires([str(f)])
assert "mbedtls" in result
def test_detect_requires_ignores_invalid_file(tmp_path):
result = _detect_requires([str(tmp_path / "missing.c")])
assert result == set()
def test_split_list_by_condition():
items = ["-Iinclude", "-Llib", "-Wall"]
matched, rest = _split_list_by_condition(
items, lambda x: x[2:] if x.startswith("-I") else None
)
assert matched == ["include"]
assert "-Llib" in rest
assert "-Wall" in rest
def test_generate_cmakelists_txt_basic(tmp_component):
src_dir = tmp_component.path / "src"
src_dir.mkdir()
f = src_dir / "main.c"
f.write_text("int main() {}")
tmp_component.data = {}
content = generate_cmakelists_txt(tmp_component)
assert "idf_component_register" in content
assert "main.c" in content
def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com"))
dep.path = tmp_path / "dep"
tmp_component.dependencies = [dep]
tmp_component.data = {
"build": {"flags": ["-Iinclude", "-Llib", "-lmylib", "-Wall", "-DTEST"]}
}
content = generate_cmakelists_txt(tmp_component)
sep = "\\\\" if os.name == "nt" else "/"
assert (
content
== f"""idf_component_register(
SRCS "src{sep}main.c"
INCLUDE_DIRS "src"
REQUIRES dep
)
target_compile_options(${{COMPONENT_LIB}} PUBLIC
"-DTEST"
)
target_compile_options(${{COMPONENT_LIB}} PRIVATE
"-Wall"
)
target_link_directories(${{COMPONENT_LIB}} INTERFACE
"lib"
)
target_link_libraries(${{COMPONENT_LIB}} INTERFACE
"mylib"
)
"""
)
def test_generate_idf_component_yml_basic(tmp_component):
tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}}
result = generate_idf_component_yml(tmp_component)
assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n"
def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com"))
dep.path = tmp_path / "dep"
tmp_component.dependencies = [dep]
tmp_component.data = {}
result = generate_idf_component_yml(tmp_component)
assert (
result
== f"""version: 1.0.0
dependencies:
dep:
version: '1.0'
override_path: {dep.path}
"""
)
def test_generate_idf_component_yml_arduino_registry_dep(tmp_component):
# Synthetic arduino-esp32 dep with no source / no path: should emit a
# version-only entry so the IDF component manager resolves it from the
# registry instead of via git.
dep = IDFComponent("espressif/arduino-esp32", "3.3.8", source=None)
tmp_component.dependencies = [dep]
tmp_component.data = {}
result = generate_idf_component_yml(tmp_component)
assert (
result
== """version: 1.0.0
dependencies:
espressif/arduino-esp32:
version: 3.3.8
"""
)
def test_generate_idf_component_yml_missing_path_reraises(tmp_component):
# A dep without a path and without a recognised source should re-raise
# the underlying RuntimeError instead of silently producing a bad manifest.
dep = IDFComponent("foo/bar", "1.0", source=None)
tmp_component.dependencies = [dep]
tmp_component.data = {}
with pytest.raises(RuntimeError):
generate_idf_component_yml(tmp_component)
def test_check_library_data_valid(esp32_idf_core):
_check_library_data({"platforms": "*", "frameworks": "*"})
def test_check_library_data_valid2(esp32_idf_core):
_check_library_data({"platforms": "*"})
def test_check_library_data_valid3(esp32_idf_core):
_check_library_data({})
def test_check_library_data_valid4(esp32_idf_core):
_check_library_data({"platforms": "espressif32", "frameworks": "*"})
def test_check_library_data_valid5(esp32_idf_core):
_check_library_data({"platforms": "*", "frameworks": "espidf"})
def test_check_library_data_invalid_platform(esp32_idf_core):
with pytest.raises(InvalidIDFComponent):
_check_library_data({"platforms": ["other"], "frameworks": "*"})
def test_check_library_data_invalid_framework(esp32_idf_core):
with pytest.raises(InvalidIDFComponent):
_check_library_data({"platforms": "*", "frameworks": ["other"]})
def test_extra_script_logs_warning(caplog, esp32_idf_core):
extra_script = "myscript.sh"
with caplog.at_level("WARNING"):
_check_library_data({"build": {"extraScript": extra_script}})
assert "not supported" in caplog.text
assert "myscript.sh" in caplog.text
def test_parse_library_json(tmp_path):
f = tmp_path / "library.json"
f.write_text(json.dumps({"name": "test"}))
result = _parse_library_json(f)
assert result["name"] == "test"
def test_parse_library_properties(tmp_path):
f = tmp_path / "library.properties"
f.write_text(
"""
name=Test
version=1.0
# description=ABCD
empty=
"""
)
result = _parse_library_properties(f)
assert result["name"] == "Test"
assert result["version"] == "1.0"
assert "empty" not in result
def test_convert_library_with_repository():
lib = Library("name", None, "https://github.com/foo/bar.git#v1.2.3")
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "1.2.3"
assert isinstance(result.source, GitSource)
def test_convert_library_missing_ref():
lib = Library("name", None, "https://github.com/foo/bar.git")
with pytest.raises(ValueError):
_convert_library_to_component(lib)
def test_convert_library_registry(monkeypatch):
lib = Library("foo/bar", "^1.0.0", None)
monkeypatch.setattr(
esphome.espidf.component,
"_get_package_from_pio_registry",
lambda o, n, r: ("foo", "bar", "1.2.3", "http://example.com/pkg.zip"),
)
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "1.2.3"
assert isinstance(result.source, URLSource)
def test_process_dependencies_adds_valid_dependency(tmp_component, monkeypatch):
tmp_component.data = {
"dependencies": [
{
"name": "foo",
"version": "1.0",
}
]
}
monkeypatch.setattr(
esphome.espidf.component,
"_generate_idf_component",
lambda lib: esphome.espidf.component.IDFComponent(
lib.name, lib.version, source=URLSource("http://dummy.com")
),
)
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
_process_dependencies(tmp_component)
assert len(tmp_component.dependencies) == 1
def test_process_dependencies_skips_invalid(tmp_component):
tmp_component.data = {
"dependencies": [
{"name": "foo", "version": "1.0", "platforms": ["arduino"]},
{"invalid": "entry"},
]
}
_process_dependencies(tmp_component)
assert tmp_component.dependencies == []
+2 -1
View File
@@ -604,7 +604,8 @@ def test_run_ota_wrapper(mock_run_ota_impl: Mock) -> None:
def test_progress_bar(capsys: CaptureFixture[str]) -> None:
"""Test ProgressBar functionality."""
progress = espota2.ProgressBar()
progress = espota2.ProgressBar("Uploading")
progress.enabled = True # Fake TTY
# Test initial update
progress.update(0.0)
+2
View File
@@ -88,6 +88,7 @@ from esphome.const import (
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
Toolchain,
)
from esphome.core import CORE, EsphomeError
from esphome.espota2 import (
@@ -148,6 +149,7 @@ def setup_core(
config[CONF_WIFI] = {CONF_USE_ADDRESS: address}
CORE.config = config
CORE.toolchain = Toolchain.PLATFORMIO
if platform is not None:
CORE.data[KEY_CORE] = {}