mirror of
https://github.com/esphome/esphome.git
synced 2025-10-24 04:33:49 +01:00
[cli] Add analyze-memory command (#11395)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -62,6 +62,40 @@ from esphome.util import (
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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):
|
class ArgsProtocol(Protocol):
|
||||||
device: list[str] | None
|
device: list[str] | None
|
||||||
@@ -892,6 +926,54 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
|||||||
return 0
|
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:
|
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||||
new_name = args.name
|
new_name = args.name
|
||||||
for c in new_name:
|
for c in new_name:
|
||||||
@@ -1007,6 +1089,7 @@ POST_CONFIG_ACTIONS = {
|
|||||||
"idedata": command_idedata,
|
"idedata": command_idedata,
|
||||||
"rename": command_rename,
|
"rename": command_rename,
|
||||||
"discover": command_discover,
|
"discover": command_discover,
|
||||||
|
"analyze-memory": command_analyze_memory,
|
||||||
}
|
}
|
||||||
|
|
||||||
SIMPLE_CONFIG_ACTIONS = [
|
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_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
|
# Keep backward compatibility with the old command line format of
|
||||||
# esphome <config> <command>.
|
# esphome <config> <command>.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ from esphome import platformio_api
|
|||||||
from esphome.__main__ import (
|
from esphome.__main__ import (
|
||||||
Purpose,
|
Purpose,
|
||||||
choose_upload_log_host,
|
choose_upload_log_host,
|
||||||
|
command_analyze_memory,
|
||||||
command_clean_all,
|
command_clean_all,
|
||||||
command_rename,
|
command_rename,
|
||||||
command_update_all,
|
command_update_all,
|
||||||
command_wizard,
|
command_wizard,
|
||||||
|
detect_external_components,
|
||||||
get_port_type,
|
get_port_type,
|
||||||
has_ip_address,
|
has_ip_address,
|
||||||
has_mqtt,
|
has_mqtt,
|
||||||
@@ -226,13 +228,47 @@ def mock_run_external_process() -> Generator[Mock]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_run_external_command() -> Generator[Mock]:
|
def mock_run_external_command_main() -> Generator[Mock]:
|
||||||
"""Mock run_external_command for testing."""
|
"""Mock run_external_command in __main__ module (different from platformio_api)."""
|
||||||
with patch("esphome.__main__.run_external_command") as mock:
|
with patch("esphome.__main__.run_external_command") as mock:
|
||||||
mock.return_value = 0 # Default to success
|
mock.return_value = 0 # Default to success
|
||||||
yield mock
|
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:
|
def test_choose_upload_log_host_with_string_default() -> None:
|
||||||
"""Test with a single string default device."""
|
"""Test with a single string default device."""
|
||||||
setup_core()
|
setup_core()
|
||||||
@@ -839,7 +875,7 @@ def test_upload_program_serial_esp8266_with_file(
|
|||||||
|
|
||||||
def test_upload_using_esptool_path_conversion(
|
def test_upload_using_esptool_path_conversion(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
mock_run_external_command: Mock,
|
mock_run_external_command_main: Mock,
|
||||||
mock_get_idedata: Mock,
|
mock_get_idedata: Mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test upload_using_esptool properly converts Path objects to strings for esptool.
|
"""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
|
assert result == 0
|
||||||
|
|
||||||
# Verify that run_external_command was called
|
# 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
|
# 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,
|
# The first argument should be esptool.main function,
|
||||||
# followed by the command arguments
|
# followed by the command arguments
|
||||||
@@ -917,7 +953,7 @@ def test_upload_using_esptool_path_conversion(
|
|||||||
|
|
||||||
def test_upload_using_esptool_with_file_path(
|
def test_upload_using_esptool_with_file_path(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
mock_run_external_command: Mock,
|
mock_run_external_command_main: Mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test upload_using_esptool with a custom file that's a Path object."""
|
"""Test upload_using_esptool with a custom file that's a Path object."""
|
||||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
|
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
|
assert result == 0
|
||||||
|
|
||||||
# Verify that run_external_command was called
|
# 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
|
# 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
|
cmd_list = list(call_args[1:]) # Skip the esptool.main function
|
||||||
|
|
||||||
# Find the firmware path in the command
|
# 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)
|
# 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"])
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user