diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index 48ecf2c1dc..f56d720ec2 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -56,6 +56,10 @@ _COMPONENT_PREFIX_LIB = "[lib]" _COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core" _COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api" +# Placement new storage suffix (generated by codegen Pvariable) +_PSTORAGE_SUFFIX = "__pstorage" + + # C++ namespace prefixes _NAMESPACE_ESPHOME = "esphome::" _NAMESPACE_STD = "std::" @@ -332,6 +336,13 @@ class MemoryAnalyzer: # Demangle C++ names if needed demangled = self._demangle_symbol(symbol_name) + # Check for placement new storage symbols (generated by codegen) + # Format: {component}__{id}__pstorage + if demangled.endswith(_PSTORAGE_SUFFIX) and ( + component := self._match_pstorage_component(demangled) + ): + return component + # Check for special component classes first (before namespace pattern) # This handles cases like esphome::ESPHomeOTAComponent which should map to ota if _NAMESPACE_ESPHOME in demangled: @@ -399,6 +410,24 @@ class MemoryAnalyzer: # Track uncategorized symbols for analysis return "other" + def _match_pstorage_component(self, symbol_name: str) -> str | None: + """Match a __pstorage symbol to its ESPHome component. + + Symbol format: {component}__{id}__pstorage + The component namespace is embedded by codegen before the double underscore. + """ + prefix = symbol_name[: -len(_PSTORAGE_SUFFIX)] + # Extract component namespace before the first double underscore + dunder_pos = prefix.find("__") + if dunder_pos == -1: + return None + component_name = prefix[:dunder_pos] + if component_name in get_esphome_components(): + return f"{_COMPONENT_PREFIX_ESPHOME}{component_name}" + if component_name in self.external_components: + return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}" + return None + def _batch_demangle_symbols(self, symbols: list[str]) -> None: """Batch demangle C++ symbol names for efficiency.""" if not symbols: diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index acaf5f4562..b7561e8ffc 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -15,6 +15,7 @@ from . import ( _COMPONENT_PREFIX_ESPHOME, _COMPONENT_PREFIX_EXTERNAL, _COMPONENT_PREFIX_LIB, + _PSTORAGE_SUFFIX, RAM_SECTIONS, MemoryAnalyzer, ) @@ -23,6 +24,17 @@ if TYPE_CHECKING: from . import ComponentMemory +def _format_pstorage_name(name: str) -> str: + """Format a __pstorage symbol as 'storage for {id}'.""" + if not name.endswith(_PSTORAGE_SUFFIX): + return name + prefix = name[: -len(_PSTORAGE_SUFFIX)] + # Strip component namespace prefix: {component}__{id} -> {id} + dunder_pos = prefix.find("__") + var_id = prefix[dunder_pos + 2 :] if dunder_pos != -1 else prefix + return f"storage for {var_id}" + + class MemoryAnalyzerCLI(MemoryAnalyzer): """Memory analyzer with CLI-specific report generation.""" @@ -148,11 +160,14 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): If section is one of the RAM sections (.data or .bss), a label like " [data]" or " [bss]" is appended. For non-RAM sections or when section is None, no section label is added. + + Placement new storage symbols are formatted as "storage for {id}". """ + display_name = _format_pstorage_name(demangled) section_label = "" if section in RAM_SECTIONS: section_label = f" [{section[1:]}]" # .data -> [data], .bss -> [bss] - return f"{demangled} ({size:,} B){section_label}" + return f"{display_name} ({size:,} B){section_label}" def _add_top_symbols(self, lines: list[str]) -> None: """Add a section showing the top largest symbols in the binary.""" @@ -175,11 +190,13 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): for i, (_, demangled, size, section, component) in enumerate(top_symbols): # Format section label section_label = f"[{section[1:]}]" if section else "" - # Truncate demangled name if too long + # Format storage symbols readably + display_name = _format_pstorage_name(demangled) + # Truncate if too long demangled_display = ( - f"{demangled[:truncate_limit]}..." - if len(demangled) > self.COL_TOP_SYMBOL_NAME - else demangled + f"{display_name[:truncate_limit]}..." + if len(display_name) > self.COL_TOP_SYMBOL_NAME + else display_name ) lines.append( f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}" @@ -573,15 +590,16 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append(f"Total size: {comp_mem.flash_total:,} B") lines.append("") - # Show all symbols above threshold for better visibility + # Show symbols above threshold, always include storage symbols large_symbols = [ (sym, dem, size, sec) for sym, dem, size, sec in sorted_symbols if size > self.SYMBOL_SIZE_THRESHOLD + or dem.endswith(_PSTORAGE_SUFFIX) ] lines.append( - f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_symbols)} symbols):" + f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):" ) for i, (symbol, demangled, size, section) in enumerate(large_symbols): lines.append( @@ -604,7 +622,10 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): # Sort by size descending sorted_ram_syms = sorted(ram_syms, key=lambda x: x[2], reverse=True) large_ram_syms = [ - s for s in sorted_ram_syms if s[2] > self.RAM_SYMBOL_SIZE_THRESHOLD + s + for s in sorted_ram_syms + if s[2] > self.RAM_SYMBOL_SIZE_THRESHOLD + or s[1].endswith(_PSTORAGE_SUFFIX) ] lines.append(f"{name} ({mem.ram_total:,} B total RAM):") @@ -622,13 +643,14 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): for symbol, demangled, size, section in large_ram_syms[:10]: # Format section label consistently by stripping leading dot section_label = section.lstrip(".") if section else "" + display_name = _format_pstorage_name(demangled) # Add ellipsis if name is truncated - demangled_display = ( - f"{demangled[:70]}..." if len(demangled) > 70 else demangled - ) - lines.append( - f" {size:>6,} B [{section_label}] {demangled_display}" + display_name = ( + f"{display_name[:70]}..." + if len(display_name) > 70 + else display_name ) + lines.append(f" {size:>6,} B [{section_label}] {display_name}") if len(large_ram_syms) > 10: lines.append(f" ... and {len(large_ram_syms) - 10} more") lines.append("") diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 3ed5d0ba37..e97bd71a48 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -584,7 +584,16 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": # For 'new' allocations, use placement new into static storage # to avoid heap fragmentation on embedded devices. the_type = id_.type - storage_name = f"{id_.id}__pstorage" + # Extract component namespace from type for memory analysis attribution + type_str = str(the_type) + # Strip leading esphome:: to get the component namespace + # e.g. esphome::dsmr::Dsmr -> dsmr, logger::Logger -> logger + bare = type_str.removeprefix("esphome::") + if "::" in bare: + component_ns = bare.split("::", maxsplit=1)[0].rstrip("_") + else: + component_ns = "esphome" + storage_name = f"{component_ns}__{id_.id}__pstorage" # Declare aligned byte array for the object storage CORE.add_global( diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index 212f61e44b..8c1278a332 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -8,7 +8,7 @@ def test_deep_sleep_setup(generate_main): main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep1.yaml") assert ( - "static deep_sleep::DeepSleepComponent *const deepsleep = reinterpret_cast(deepsleep__pstorage);" + "static deep_sleep::DeepSleepComponent *const deepsleep = reinterpret_cast(deep_sleep__deepsleep__pstorage);" in main_cpp ) assert "new(deepsleep) deep_sleep::DeepSleepComponent();" in main_cpp diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 9003a4ee5d..6f73888c7d 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -242,11 +242,11 @@ def test_image_generation( main_cpp = generate_main(component_config_path("image_test.yaml")) assert "uint8_t_id[] PROGMEM = {0x24, 0x21, 0x24, 0x21" in main_cpp assert ( - "alignas(image::Image) static unsigned char cat_img__pstorage[sizeof(image::Image)];" + "alignas(image::Image) static unsigned char image__cat_img__pstorage[sizeof(image::Image)];" in main_cpp ) assert ( - "static image::Image *const cat_img = reinterpret_cast(cat_img__pstorage);" + "static image::Image *const cat_img = reinterpret_cast(image__cat_img__pstorage);" in main_cpp ) assert ( diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index e6f344b086..1ae8cc644e 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -119,11 +119,11 @@ def test_code_generation( main_cpp = generate_main(component_fixture_path("mipi_dsi.yaml")) assert ( - "alignas(mipi_dsi::MIPI_DSI) static unsigned char p4_nano__pstorage[sizeof(mipi_dsi::MIPI_DSI)];" + "alignas(mipi_dsi::MIPI_DSI) static unsigned char mipi_dsi__p4_nano__pstorage[sizeof(mipi_dsi::MIPI_DSI)];" in main_cpp ) assert ( - "static mipi_dsi::MIPI_DSI *const p4_nano = reinterpret_cast(p4_nano__pstorage);" + "static mipi_dsi::MIPI_DSI *const p4_nano = reinterpret_cast(mipi_dsi__p4_nano__pstorage);" in main_cpp ) assert ( diff --git a/tests/component_tests/status_led/test_status_led.py b/tests/component_tests/status_led/test_status_led.py index 0e96e631f5..f7e0a9de86 100644 --- a/tests/component_tests/status_led/test_status_led.py +++ b/tests/component_tests/status_led/test_status_led.py @@ -13,11 +13,11 @@ def test_status_led_generation( """Test status_led generation.""" main_cpp = generate_main(component_config_path("status_led_test.yaml")) assert ( - "alignas(status_led::StatusLED) static unsigned char status_led_statusled_id__pstorage[sizeof(status_led::StatusLED)];" + "alignas(status_led::StatusLED) static unsigned char status_led__status_led_statusled_id__pstorage[sizeof(status_led::StatusLED)];" in main_cpp ) assert ( - "static status_led::StatusLED *const status_led_statusled_id = reinterpret_cast(status_led_statusled_id__pstorage);" + "static status_led::StatusLED *const status_led_statusled_id = reinterpret_cast(status_led__status_led_statusled_id__pstorage);" in main_cpp ) assert "new(status_led_statusled_id) status_led::StatusLED(" in main_cpp diff --git a/tests/unit_tests/analyze_memory/test_pstorage_attribution.py b/tests/unit_tests/analyze_memory/test_pstorage_attribution.py new file mode 100644 index 0000000000..a57b283f44 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_pstorage_attribution.py @@ -0,0 +1,90 @@ +"""Tests for __pstorage symbol attribution in memory analyzer.""" + +from unittest.mock import patch + +from esphome.analyze_memory import _PSTORAGE_SUFFIX, MemoryAnalyzer + + +def _make_analyzer(external_components: set[str] | None = None) -> MemoryAnalyzer: + """Create a MemoryAnalyzer with mocked dependencies.""" + with patch.object(MemoryAnalyzer, "__init__", lambda self, *a, **kw: None): + analyzer = MemoryAnalyzer.__new__(MemoryAnalyzer) + analyzer.external_components = external_components or set() + return analyzer + + +def test_pstorage_suffix_constant() -> None: + """Verify the suffix constant matches what codegen produces.""" + assert _PSTORAGE_SUFFIX == "__pstorage" + + +def test_match_pstorage_simple_component() -> None: + """Simple component name like 'logger'.""" + analyzer = _make_analyzer() + result = analyzer._match_pstorage_component("logger__logger_id__pstorage") + assert result == "[esphome]logger" + + +def test_match_pstorage_underscore_component() -> None: + """Component with underscore like 'web_server'.""" + analyzer = _make_analyzer() + result = analyzer._match_pstorage_component("web_server__webserver_id__pstorage") + assert result == "[esphome]web_server" + + +def test_match_pstorage_api() -> None: + """API component.""" + analyzer = _make_analyzer() + result = analyzer._match_pstorage_component("api__apiserver_id__pstorage") + assert result == "[esphome]api" + + +def test_match_pstorage_deep_sleep() -> None: + """Component with underscore: deep_sleep.""" + analyzer = _make_analyzer() + result = analyzer._match_pstorage_component("deep_sleep__deepsleep__pstorage") + assert result == "[esphome]deep_sleep" + + +def test_match_pstorage_status_led() -> None: + """Component with underscore: status_led.""" + analyzer = _make_analyzer() + result = analyzer._match_pstorage_component("status_led__statusled_id__pstorage") + assert result == "[esphome]status_led" + + +def test_match_pstorage_external_component() -> None: + """External component should be attributed correctly.""" + analyzer = _make_analyzer(external_components={"my_custom"}) + result = analyzer._match_pstorage_component("my_custom__thing_id__pstorage") + assert result == "[external]my_custom" + + +def test_match_pstorage_no_dunder_returns_none() -> None: + """Symbol without double underscore separator returns None.""" + analyzer = _make_analyzer() + result = analyzer._match_pstorage_component("something__pstorage") + assert result is None + + +def test_match_pstorage_unknown_component_returns_none() -> None: + """Unknown component namespace returns None.""" + analyzer = _make_analyzer() + result = analyzer._match_pstorage_component("nonexistent__thing_id__pstorage") + assert result is None + + +def test_match_pstorage_esphome_component() -> None: + """esphome:: namespace types map to the esphome component.""" + analyzer = _make_analyzer() + result = analyzer._match_pstorage_component( + "esphome__esphomeotacomponent_id__pstorage" + ) + assert result == "[esphome]esphome" + + +def test_match_pstorage_user_id_with_component_prefix() -> None: + """User-chosen ID that happens to contain a component name.""" + analyzer = _make_analyzer() + result = analyzer._match_pstorage_component("logger__relay1__pstorage") + assert result == "[esphome]logger"