diff --git a/esphome/__main__.py b/esphome/__main__.py index 707ad036096..c3502db4bae 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Protocol # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. from esphome import const, platform_hooks -from esphome.build_helpers.native import native_backend +from esphome.build_helpers.native import analysis_backend, native_backend from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, @@ -2015,8 +2015,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: from esphome.analyze_memory.ram_strings import RamStringsAnalyzer # Refuse an unsupported toolchain before paying for a full compile - native_toolchain = native_backend() - if native_toolchain is None and not CORE.using_toolchain_platformio: + analysis_toolchain = analysis_backend() + if analysis_toolchain is None and not CORE.using_toolchain_platformio: _LOGGER.error( "analyze-memory is not supported with the '%s' toolchain on %s; " "re-run with --toolchain platformio", @@ -2036,9 +2036,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None - if native_toolchain is not None: - objdump = native_toolchain.get_objdump_path() - readelf = native_toolchain.get_readelf_path() + if analysis_toolchain is not None: + objdump = analysis_toolchain.get_objdump_path() + readelf = analysis_toolchain.get_readelf_path() for tool in (objdump, readelf): if not tool.is_file(): # The analyzer would silently fall back to host @@ -2052,7 +2052,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: objdump_path = str(objdump) readelf_path = str(readelf) - firmware_elf = native_toolchain.get_elf_path() + firmware_elf = analysis_toolchain.get_elf_path() if not firmware_elf.is_file(): # The analyzer swallows tool failures, so a missing ELF would # produce an exit-0 zeroed report diff --git a/esphome/build_helpers/native.py b/esphome/build_helpers/native.py index cde526b8033..24499c3ccde 100644 --- a/esphome/build_helpers/native.py +++ b/esphome/build_helpers/native.py @@ -32,3 +32,21 @@ def native_backend() -> ModuleType | None: f"module for platform {CORE.target_platform}" ) return importlib.import_module(module_path) + + +# Binutils and the linked image for memory analysis, for toolchains that build +# without PlatformIO but have no native build backend (which supplies them) +ANALYSIS_TOOLCHAIN_MODULES = { + ("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain", +} + + +def analysis_backend() -> ModuleType | None: + """The module giving objdump, readelf and the ELF of a non-PlatformIO build. + + None means PlatformIO's idedata supplies them (or nothing can). + """ + if (native := native_backend()) is not None: + return native + module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain)) + return importlib.import_module(module_path) if module_path else None diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 5e2cf197fbb..a2fd6dd075e 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -80,6 +80,11 @@ def _get_toolchain_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "toolchains" / version +def get_toolchain_bin_path() -> Path: + """The pinned Zephyr SDK's arm-zephyr-eabi binaries (objdump, readelf, ...).""" + return _get_toolchain_path(TOOLCHAIN_VERSION) / "arm-zephyr-eabi" / "bin" + + _SITECUSTOMIZE = """\ import os, stat, shutil _orig = shutil.rmtree diff --git a/esphome/components/nrf52/toolchain.py b/esphome/components/nrf52/toolchain.py new file mode 100644 index 00000000000..745a8671800 --- /dev/null +++ b/esphome/components/nrf52/toolchain.py @@ -0,0 +1,23 @@ +"""Binutils and the linked image of an nRF52 sdk-nrf build, for memory analysis.""" + +from pathlib import Path + +from esphome.core import CORE + +from .framework import get_toolchain_bin_path + + +def get_objdump_path() -> Path: + return get_toolchain_bin_path() / "arm-zephyr-eabi-objdump" + + +def get_readelf_path() -> Path: + return get_toolchain_bin_path() / "arm-zephyr-eabi-readelf" + + +def get_elf_path() -> Path: + """The linked Zephyr image; the SDK nests it one level deeper from 2.9.2.""" + nested = CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf") + if nested.is_file(): + return nested + return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf") diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index b5e7181269b..4f11be0cb8d 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7489,6 +7489,8 @@ def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None: [ (PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), (PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + # No native build backend, but its binutils and ELF are known + (PLATFORM_NRF52, Toolchain.SDK_NRF, "esphome.components.nrf52.toolchain"), ], ) def test_command_analyze_memory_native_toolchains( @@ -7708,7 +7710,12 @@ def test_command_analyze_memory_unsupported_toolchain( mock_write_cpp.return_value = 0 mock_compile_program.return_value = 0 - result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) + # Every toolchain has analysis hooks today; drop sdk-nrf's to stand in for + # one that does not + with patch.dict( + "esphome.build_helpers.native.ANALYSIS_TOOLCHAIN_MODULES", clear=True + ): + result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) assert result == 1 assert "analyze-memory is not supported" in caplog.text diff --git a/tests/unit_tests/test_nrf52_toolchain.py b/tests/unit_tests/test_nrf52_toolchain.py new file mode 100644 index 00000000000..2b51b2c1f6f --- /dev/null +++ b/tests/unit_tests/test_nrf52_toolchain.py @@ -0,0 +1,42 @@ +"""Tests for the nRF52 sdk-nrf memory analysis hooks.""" + +from pathlib import Path + +import pytest + +from esphome.components.nrf52 import toolchain +from esphome.components.nrf52.framework import TOOLCHAIN_VERSION, get_sdk_nrf_tools_path +from esphome.core import CORE + + +@pytest.fixture +def nrf52_build(tmp_path: Path) -> Path: + CORE.name = "test_device" + CORE.build_path = tmp_path / "build" / "test_device" + return CORE.build_path / ".pioenvs" / "test_device" / "zephyr" + + +def test_binutils_come_from_the_pinned_zephyr_sdk() -> None: + bin_path = ( + get_sdk_nrf_tools_path() + / "toolchains" + / TOOLCHAIN_VERSION + / "arm-zephyr-eabi" + / "bin" + ) + assert toolchain.get_objdump_path() == bin_path / "arm-zephyr-eabi-objdump" + assert toolchain.get_readelf_path() == bin_path / "arm-zephyr-eabi-readelf" + + +def test_elf_prefers_the_nested_sdk_layout(nrf52_build: Path) -> None: + nested = nrf52_build / "zephyr" / "zephyr.elf" + nested.parent.mkdir(parents=True) + nested.write_text("") + (nrf52_build / "zephyr.elf").write_text("") + assert toolchain.get_elf_path() == nested + + +def test_elf_falls_back_to_the_flat_layout(nrf52_build: Path) -> None: + # SDKs before 2.9.2 wrote the image one level up; a missing ELF is + # reported by the caller under this path + assert toolchain.get_elf_path() == nrf52_build / "zephyr.elf"