From 6a183679496fb13e464ec21728e25a200df40238 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Oct 2025 15:26:37 -1000 Subject: [PATCH] [cli] Add `analyze-memory` command (#11395) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 91 +++++++++++ tests/unit_tests/test_main.py | 275 +++++++++++++++++++++++++++++++++- 2 files changed, 358 insertions(+), 8 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 982e00f5e1..26e5ae7424 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -62,6 +62,40 @@ from esphome.util import ( _LOGGER = logging.getLogger(__name__) +# Special non-component keys that appear in configs +_NON_COMPONENT_KEYS = frozenset( + { + CONF_ESPHOME, + "substitutions", + "packages", + "globals", + "external_components", + "<<", + } +) + + +def detect_external_components(config: ConfigType) -> set[str]: + """Detect external/custom components in the configuration. + + External components are those that appear in the config but are not + part of ESPHome's built-in components and are not special config keys. + + Args: + config: The ESPHome configuration dictionary + + Returns: + A set of external component names + """ + from esphome.analyze_memory.helpers import get_esphome_components + + builtin_components = get_esphome_components() + return { + key + for key in config + if key not in builtin_components and key not in _NON_COMPONENT_KEYS + } + class ArgsProtocol(Protocol): device: list[str] | None @@ -892,6 +926,54 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: return 0 +def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: + """Analyze memory usage by component. + + 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 + + # Always compile to ensure fresh data (fast if no changes - just relinks) + exit_code = write_cpp(config) + if exit_code != 0: + return exit_code + exit_code = compile_program(args, config) + if exit_code != 0: + return exit_code + _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 + + firmware_elf = Path(idedata.firmware_elf_path) + + # Extract external components from config + external_components = detect_external_components(config) + _LOGGER.debug("Detected external components: %s", external_components) + + # Perform memory analysis + _LOGGER.info("Analyzing memory usage...") + analyzer = MemoryAnalyzerCLI( + str(firmware_elf), + idedata.objdump_path, + idedata.readelf_path, + external_components, + ) + analyzer.analyze() + + # Generate and display report + report = analyzer.generate_report() + print() + print(report) + + return 0 + + def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_name = args.name for c in new_name: @@ -1007,6 +1089,7 @@ POST_CONFIG_ACTIONS = { "idedata": command_idedata, "rename": command_rename, "discover": command_discover, + "analyze-memory": command_analyze_memory, } SIMPLE_CONFIG_ACTIONS = [ @@ -1292,6 +1375,14 @@ def parse_args(argv): ) parser_rename.add_argument("name", help="The new name for the device.", type=str) + parser_analyze_memory = subparsers.add_parser( + "analyze-memory", + help="Analyze memory usage by component.", + ) + parser_analyze_memory.add_argument( + "configuration", help="Your YAML configuration file(s).", nargs="+" + ) + # Keep backward compatibility with the old command line format of # esphome . # diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 73dfe359f0..9119c88502 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -17,10 +17,12 @@ from esphome import platformio_api from esphome.__main__ import ( Purpose, choose_upload_log_host, + command_analyze_memory, command_clean_all, command_rename, command_update_all, command_wizard, + detect_external_components, get_port_type, has_ip_address, has_mqtt, @@ -226,13 +228,47 @@ def mock_run_external_process() -> Generator[Mock]: @pytest.fixture -def mock_run_external_command() -> Generator[Mock]: - """Mock run_external_command for testing.""" +def mock_run_external_command_main() -> Generator[Mock]: + """Mock run_external_command in __main__ module (different from platformio_api).""" with patch("esphome.__main__.run_external_command") as mock: mock.return_value = 0 # Default to success yield mock +@pytest.fixture +def mock_write_cpp() -> Generator[Mock]: + """Mock write_cpp for testing.""" + with patch("esphome.__main__.write_cpp") as mock: + mock.return_value = 0 # Default to success + yield mock + + +@pytest.fixture +def mock_compile_program() -> Generator[Mock]: + """Mock compile_program for testing.""" + with patch("esphome.__main__.compile_program") as mock: + mock.return_value = 0 # Default to success + yield mock + + +@pytest.fixture +def mock_get_esphome_components() -> Generator[Mock]: + """Mock get_esphome_components for testing.""" + with patch("esphome.analyze_memory.helpers.get_esphome_components") as mock: + mock.return_value = {"logger", "api", "ota"} + yield mock + + +@pytest.fixture +def mock_memory_analyzer_cli() -> Generator[Mock]: + """Mock MemoryAnalyzerCLI for testing.""" + with patch("esphome.analyze_memory.cli.MemoryAnalyzerCLI") as mock_class: + mock_analyzer = MagicMock() + mock_analyzer.generate_report.return_value = "Mock Memory Report" + mock_class.return_value = mock_analyzer + yield mock_class + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() @@ -839,7 +875,7 @@ def test_upload_program_serial_esp8266_with_file( def test_upload_using_esptool_path_conversion( tmp_path: Path, - mock_run_external_command: Mock, + mock_run_external_command_main: Mock, mock_get_idedata: Mock, ) -> None: """Test upload_using_esptool properly converts Path objects to strings for esptool. @@ -875,10 +911,10 @@ def test_upload_using_esptool_path_conversion( assert result == 0 # Verify that run_external_command was called - assert mock_run_external_command.call_count == 1 + assert mock_run_external_command_main.call_count == 1 # Get the actual call arguments - call_args = mock_run_external_command.call_args[0] + call_args = mock_run_external_command_main.call_args[0] # The first argument should be esptool.main function, # followed by the command arguments @@ -917,7 +953,7 @@ def test_upload_using_esptool_path_conversion( def test_upload_using_esptool_with_file_path( tmp_path: Path, - mock_run_external_command: Mock, + mock_run_external_command_main: Mock, ) -> None: """Test upload_using_esptool with a custom file that's a Path object.""" setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") @@ -934,10 +970,10 @@ def test_upload_using_esptool_with_file_path( assert result == 0 # Verify that run_external_command was called - mock_run_external_command.assert_called_once() + mock_run_external_command_main.assert_called_once() # Get the actual call arguments - call_args = mock_run_external_command.call_args[0] + call_args = mock_run_external_command_main.call_args[0] cmd_list = list(call_args[1:]) # Skip the esptool.main function # Find the firmware path in the command @@ -2273,3 +2309,226 @@ def test_show_logs_api_mqtt_timeout_fallback( # Verify run_logs was called with only the static IP (MQTT failed) mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"]) + + +def test_detect_external_components_no_external( + mock_get_esphome_components: Mock, +) -> None: + """Test detect_external_components with no external components.""" + config = { + CONF_ESPHOME: {CONF_NAME: "test_device"}, + "logger": {}, + "api": {}, + } + + result = detect_external_components(config) + + assert result == set() + mock_get_esphome_components.assert_called_once() + + +def test_detect_external_components_with_external( + mock_get_esphome_components: Mock, +) -> None: + """Test detect_external_components detects external components.""" + config = { + CONF_ESPHOME: {CONF_NAME: "test_device"}, + "logger": {}, # Built-in + "api": {}, # Built-in + "my_custom_sensor": {}, # External + "another_custom": {}, # External + "external_components": [], # Special key, not a component + "substitutions": {}, # Special key, not a component + } + + result = detect_external_components(config) + + assert result == {"my_custom_sensor", "another_custom"} + mock_get_esphome_components.assert_called_once() + + +def test_detect_external_components_filters_special_keys( + mock_get_esphome_components: Mock, +) -> None: + """Test detect_external_components filters out special config keys.""" + config = { + CONF_ESPHOME: {CONF_NAME: "test_device"}, + "substitutions": {"key": "value"}, + "packages": {}, + "globals": [], + "external_components": [], + "<<": {}, # YAML merge key + } + + result = detect_external_components(config) + + assert result == set() + mock_get_esphome_components.assert_called_once() + + +def test_command_analyze_memory_success( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_idedata: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, +) -> None: + """Test command_analyze_memory with successful compilation and analysis.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") + + # Create firmware.elf file + firmware_path = ( + tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device" + ) + firmware_path.mkdir(parents=True, exist_ok=True) + firmware_elf = firmware_path / "firmware.elf" + firmware_elf.write_text("mock elf file") + + # Mock idedata + mock_idedata_obj = MagicMock(spec=platformio_api.IDEData) + mock_idedata_obj.firmware_elf_path = str(firmware_elf) + mock_idedata_obj.objdump_path = "/path/to/objdump" + mock_idedata_obj.readelf_path = "/path/to/readelf" + mock_get_idedata.return_value = mock_idedata_obj + + config = { + CONF_ESPHOME: {CONF_NAME: "test_device"}, + "logger": {}, + } + + args = MockArgs() + + result = command_analyze_memory(args, config) + + assert result == 0 + + # Verify compilation was done + mock_write_cpp.assert_called_once_with(config) + mock_compile_program.assert_called_once_with(args, config) + + # Verify analyzer was created with correct parameters + mock_memory_analyzer_cli.assert_called_once_with( + str(firmware_elf), + "/path/to/objdump", + "/path/to/readelf", + set(), # No external components + ) + + # Verify analysis was run + mock_analyzer = mock_memory_analyzer_cli.return_value + mock_analyzer.analyze.assert_called_once() + mock_analyzer.generate_report.assert_called_once() + + # Verify report was printed + captured = capfd.readouterr() + assert "Mock Memory Report" in captured.out + + +def test_command_analyze_memory_with_external_components( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_idedata: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, +) -> None: + """Test command_analyze_memory detects external components.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") + + # Create firmware.elf file + firmware_path = ( + tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device" + ) + firmware_path.mkdir(parents=True, exist_ok=True) + firmware_elf = firmware_path / "firmware.elf" + firmware_elf.write_text("mock elf file") + + # Mock idedata + mock_idedata_obj = MagicMock(spec=platformio_api.IDEData) + mock_idedata_obj.firmware_elf_path = str(firmware_elf) + mock_idedata_obj.objdump_path = "/path/to/objdump" + mock_idedata_obj.readelf_path = "/path/to/readelf" + mock_get_idedata.return_value = mock_idedata_obj + + config = { + CONF_ESPHOME: {CONF_NAME: "test_device"}, + "logger": {}, + "my_custom_component": {"param": "value"}, # External component + "external_components": [{"source": "github://user/repo"}], # Not a component + } + + args = MockArgs() + + result = command_analyze_memory(args, config) + + assert result == 0 + + # Verify analyzer was created with external components detected + mock_memory_analyzer_cli.assert_called_once_with( + str(firmware_elf), + "/path/to/objdump", + "/path/to/readelf", + {"my_custom_component"}, # External component detected + ) + + +def test_command_analyze_memory_write_cpp_fails( + tmp_path: Path, + mock_write_cpp: Mock, +) -> None: + """Test command_analyze_memory when write_cpp fails.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") + + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + mock_write_cpp.return_value = 1 # Failure + + result = command_analyze_memory(args, config) + + assert result == 1 + mock_write_cpp.assert_called_once_with(config) + + +def test_command_analyze_memory_compile_fails( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + """Test command_analyze_memory when compilation fails.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") + + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + mock_compile_program.return_value = 1 # Compilation failed + + result = command_analyze_memory(args, config) + + assert result == 1 + mock_write_cpp.assert_called_once_with(config) + mock_compile_program.assert_called_once_with(args, config) + + +def test_command_analyze_memory_no_idedata( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_idedata: Mock, +) -> None: + """Test command_analyze_memory when idedata cannot be retrieved.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") + + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + mock_get_idedata.return_value = None # Failed to get idedata + + with caplog.at_level(logging.ERROR): + result = command_analyze_memory(args, config) + + assert result == 1 + assert "Failed to get IDE data for memory analysis" in caplog.text