From 129aebe8f42277772d4b7280108cb3fcf2a74be9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:21 -0400 Subject: [PATCH] [esp32] Support `esphome idedata` with the native ESP-IDF toolchain (#17040) --- esphome/__main__.py | 15 ++++++++++++ esphome/espidf/toolchain.py | 1 + tests/unit_tests/test_espidf_toolchain.py | 28 +++++++++++++++++++---- tests/unit_tests/test_main.py | 26 +++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 27dd878495..bda3dcbd05 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1771,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json + if CORE.using_toolchain_esp_idf: + # Native ESP-IDF derives idedata from the build's compile_commands.json, + # so the configuration must already be compiled. + from esphome.espidf import toolchain as espidf_toolchain + + idedata = espidf_toolchain.get_idedata() + if idedata is None: + _LOGGER.error( + "No idedata available; compile the configuration first", + ) + return 1 + + print(json.dumps(idedata, indent=2) + "\n") + return 0 + if not CORE.using_toolchain_platformio: _LOGGER.error( "The idedata command is not compatible with %s toolchain", diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index c622a2dd36..000ce739db 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -472,6 +472,7 @@ def get_idedata() -> dict | None: pass data = idedata_from_build(compile_commands) + data["prog_path"] = str(get_elf_path()) cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return data diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index b2309439f9..017d8c49b4 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "g++"} - assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + prog_path = str(toolchain.get_elf_path()) + assert result == {"cxx_path": "g++", "prog_path": prog_path} + assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path} def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: @@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "fresh"} + assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: @@ -147,7 +148,26 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "regen"} + assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())} + + +def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None: + """The idedata exposes prog_path (the ELF) so consumers like build-action + can locate firmware.factory.bin / firmware.ota.bin as its siblings.""" + compile_commands, _ = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ): + result = toolchain.get_idedata() + + # Use Path semantics so the contract holds on Windows too (backslashes). + prog_path = Path(result["prog_path"]) + assert prog_path.name == "firmware.elf" + assert prog_path.parent.name == "build" def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e44f746a75..acd39cedc6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -32,6 +32,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_idedata, command_rename, command_run, command_update_all, @@ -6257,3 +6258,28 @@ def test_command_run_defaults_subscribe_states_true( mock_run_logs.assert_called_once_with( CORE.config, ["192.168.1.100"], subscribe_states=True ) + + +def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: + """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_esp_idf_no_build_errors() -> None: + """Under ESP-IDF, a missing build (no idedata) returns an error, not a crash.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + + with patch("esphome.espidf.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1