From 9dd7dcb06d17575dcedf9f40bcda8a32a14390d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jul 2026 20:07:14 -1000 Subject: [PATCH] [espidf] Also skip installing gdb and ULP toolchains ESPHome never runs (#17687) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- esphome/espidf/framework.py | 63 ++++++++++++++--------- tests/unit_tests/test_espidf_framework.py | 47 +++++++++++------ 2 files changed, 71 insertions(+), 39 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f1544e9d464..aedbeba69e9 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -551,30 +551,46 @@ def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: ) -def _patch_tools_json_demote_openocd(framework_path: Path) -> None: - """Demote openocd-esp32 from ``install: always`` to ``install: on_request``. +# Tools marked ``install: always`` in tools.json that no ESPHome build ever +# runs. openocd-esp32 is a JTAG debug server (its post-install check also +# fails outright on systems without libusb-1.0, #17685). The gdb bundles are +# debuggers used only by ``idf.py gdb``/``idf.py monitor`` flows ESPHome never +# invokes; stack decoding uses addr2line from the compiler toolchains instead. +# esp32ulp-elf is the ULP coprocessor toolchain, and ESPHome excludes the IDF +# ``ulp`` component from every build. esp-rom-elfs stays required: the cmake +# gdbinit generation reads ESP_ROM_ELF_DIR during every configure and warns +# when it is missing. +_UNUSED_IDF_TOOLS: tuple[str, ...] = ( + "esp32ulp-elf", + "openocd-esp32", + "riscv32-esp-elf-gdb", + "xtensa-esp-elf-gdb", +) - ``idf_tools.py install required`` installs every tool marked ``always`` in - tools.json and validates each one after extraction by running its version - command. openocd links against libusb-1.0, which minimal systems (bare LXC - containers, slim images) often lack, so that one validation aborted the - whole framework install and left it permanently retrying (#17685) — even - though ESPHome never runs openocd (it is a JTAG debugging tool). Demoting - it drops it from the ``required`` set: it is no longer downloaded or - validated, and the tool-path export treats a missing ``on_request`` tool - as fine. A user who wants it can still name ``openocd-esp32`` explicitly - in ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type - filtering. - Because this runs on every install check, an install stuck in the - failing state (which never wrote its stamp file) heals on the next - build without a clean. +def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None: + """Demote tools ESPHome never runs from ``install: always`` to ``on_request``. + + ``idf_tools.py install required`` downloads every tool marked ``always`` + in tools.json and validates each one after extraction by running its + version command. Demoting the tools in ``_UNUSED_IDF_TOOLS`` drops them + from the ``required`` set: they are no longer downloaded or validated, + and the tool-path export treats a missing ``on_request`` tool as fine. + Besides the download and disk savings, this makes the openocd libusb + validation failure (#17685) impossible; because this runs on every + install check, an install stuck in that failing state (which never wrote + its stamp file) heals on the next build without a clean. A user who + wants one of these tools can still name it explicitly in + ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type filtering. """ def apply_patch(data: dict) -> bool: changed = False for tool in data.get("tools", []): - if tool.get("name") == "openocd-esp32" and tool.get("install") == "always": + if ( + tool.get("name") in _UNUSED_IDF_TOOLS + and tool.get("install") == "always" + ): tool["install"] = "on_request" changed = True return changed @@ -582,9 +598,8 @@ def _patch_tools_json_demote_openocd(framework_path: Path) -> None: _patch_tools_json( framework_path, apply_patch, - "Patched %s to make openocd-esp32 optional (not needed for " - "building, and its install check fails on systems without " - "libusb-1.0).", + "Patched %s to skip installing tools ESPHome does not use " + "(openocd, gdb, ULP toolchain).", ) @@ -779,10 +794,10 @@ def _check_esphome_idf_framework_install( # a pre-patch tools.json get fixed up without forcing a clean. _patch_tools_json_for_linux_arm64(framework_path) - # Drop openocd-esp32 from the required tool set on every invocation so - # an install that previously failed on its libusb check recovers on the - # next build. - _patch_tools_json_demote_openocd(framework_path) + # Drop tools ESPHome never runs from the required tool set on every + # invocation, so an install that previously failed on the openocd libusb + # check recovers on the next build. + _patch_tools_json_demote_unused_tools(framework_path) # 3. Check if the framework tools are the same and correctly installed if not install: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index e1408e538a1..7de1557bd6e 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -29,7 +29,7 @@ from esphome.espidf.framework import ( _get_python_env_path, _get_python_version, _parse_git_source, - _patch_tools_json_demote_openocd, + _patch_tools_json_demote_unused_tools, _patch_tools_json_for_linux_arm64, _prefetch_idf_tool_archives, _windows_long_paths_enabled, @@ -352,7 +352,7 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), - patch("esphome.espidf.framework._patch_tools_json_demote_openocd"), + patch("esphome.espidf.framework._patch_tools_json_demote_unused_tools"), patch("esphome.espidf.framework._prefetch_idf_tool_archives"), patch("esphome.espidf.framework._write_stamp"), patch("esphome.espidf.framework._check_stamp", return_value=True), @@ -952,28 +952,37 @@ def test_get_tool_downloads_inprocess_explicit_tool_specs( # --------------------------------------------------------------------------- -# _patch_tools_json_demote_openocd (openocd-esp32 made optional) +# _patch_tools_json_demote_unused_tools (openocd, gdb, ULP toolchain optional) # --------------------------------------------------------------------------- -def test_demote_openocd_patches_install_type(tmp_path: Path) -> None: +def test_demote_unused_tools_patches_install_type(tmp_path: Path) -> None: tools_json = _write_tools_json( tmp_path, { "tools": [ {"name": "openocd-esp32", "install": "always"}, - {"name": "cmake", "install": "always"}, + {"name": "xtensa-esp-elf-gdb", "install": "always"}, + {"name": "riscv32-esp-elf-gdb", "install": "always"}, + {"name": "esp32ulp-elf", "install": "always"}, + {"name": "xtensa-esp-elf", "install": "always"}, + {"name": "esp-rom-elfs", "install": "always"}, ] }, ) - _patch_tools_json_demote_openocd(tmp_path) + _patch_tools_json_demote_unused_tools(tmp_path) data = json.loads(tools_json.read_text(encoding="utf-8")) - openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32") - cmake = next(t for t in data["tools"] if t["name"] == "cmake") - assert openocd["install"] == "on_request" - # other tools are left untouched - assert cmake["install"] == "always" + install_types = {t["name"]: t["install"] for t in data["tools"]} + assert install_types == { + "openocd-esp32": "on_request", + "xtensa-esp-elf-gdb": "on_request", + "riscv32-esp-elf-gdb": "on_request", + "esp32ulp-elf": "on_request", + # the compiler toolchain and ROM ELFs stay required + "xtensa-esp-elf": "always", + "esp-rom-elfs": "always", + } def test_patch_tools_json_unexpected_structure_warns_and_skips( @@ -985,16 +994,24 @@ def test_patch_tools_json_unexpected_structure_warns_and_skips( tools_json = tools_dir / "tools.json" tools_json.write_text('["not", "a", "dict"]', encoding="utf-8") before = tools_json.read_text(encoding="utf-8") - _patch_tools_json_demote_openocd(tmp_path) # AttributeError -> skip + _patch_tools_json_demote_unused_tools(tmp_path) # AttributeError -> skip assert tools_json.read_text(encoding="utf-8") == before -def test_demote_openocd_already_patched_is_noop(tmp_path: Path) -> None: +def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None: tools_json = _write_tools_json( - tmp_path, {"tools": [{"name": "openocd-esp32", "install": "on_request"}]} + tmp_path, + { + "tools": [ + {"name": "openocd-esp32", "install": "on_request"}, + {"name": "xtensa-esp-elf-gdb", "install": "on_request"}, + {"name": "riscv32-esp-elf-gdb", "install": "on_request"}, + {"name": "esp32ulp-elf", "install": "on_request"}, + ] + }, ) before = tools_json.read_text(encoding="utf-8") - _patch_tools_json_demote_openocd(tmp_path) + _patch_tools_json_demote_unused_tools(tmp_path) assert tools_json.read_text(encoding="utf-8") == before