mirror of
https://github.com/esphome/esphome.git
synced 2026-05-24 01:37:15 +08:00
[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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 = ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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}))
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
Reference in New Issue
Block a user