diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0842248db9..7a4d8bf929 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -548,7 +548,7 @@ jobs: with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} - - name: Build and compile with test_build_components + - name: Build, compile, and analyze memory id: extract run: | . venv/bin/activate @@ -563,38 +563,32 @@ jobs: component_list=$(echo "$components" | jq -r 'join(",")') echo "Compiling with test_build_components.py..." - python script/test_build_components.py \ - -e compile \ - -c "$component_list" \ - -t "$platform" 2>&1 | \ - python script/ci_memory_impact_extract.py --output-env - - name: Find and upload final ELF file - run: | - # Note: test_build_components.py may run multiple builds, but each overwrites - # the previous firmware.elf. The memory totals (RAM/Flash) are already summed - # by ci_memory_impact_extract.py. This ELF is from the last build and is used - # for detailed component breakdown (if available). - mkdir -p ./elf-artifacts/target - # Find the most recent firmware.elf - if [ -d ~/.esphome/build ]; then - elf_file=$(find ~/.esphome/build -name "firmware.elf" -type f -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-) + # Find most recent build directory for detailed analysis + build_dir=$(find ~/.esphome/build -type d -maxdepth 1 -mindepth 1 -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2- || echo "") - if [ -n "$elf_file" ] && [ -f "$elf_file" ]; then - echo "Found final ELF file: $elf_file" - cp "$elf_file" "./elf-artifacts/target/firmware.elf" - else - echo "Warning: No ELF file found in ~/.esphome/build" - ls -la ~/.esphome/build/ || true - fi + # Run build and extract memory, with optional detailed analysis + if [ -n "$build_dir" ]; then + python script/test_build_components.py \ + -e compile \ + -c "$component_list" \ + -t "$platform" 2>&1 | \ + python script/ci_memory_impact_extract.py \ + --output-env \ + --build-dir "$build_dir" \ + --output-json memory-analysis-target.json else - echo "Warning: ~/.esphome/build directory not found" + python script/test_build_components.py \ + -e compile \ + -c "$component_list" \ + -t "$platform" 2>&1 | \ + python script/ci_memory_impact_extract.py --output-env fi - - name: Upload ELF artifact + - name: Upload memory analysis JSON uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: memory-impact-target-elf - path: ./elf-artifacts/target/firmware.elf + name: memory-analysis-target + path: memory-analysis-target.json if-no-files-found: warn retention-days: 1 @@ -621,7 +615,7 @@ jobs: with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} - - name: Build and compile with test_build_components + - name: Build, compile, and analyze memory id: extract run: | . venv/bin/activate @@ -636,38 +630,32 @@ jobs: component_list=$(echo "$components" | jq -r 'join(",")') echo "Compiling with test_build_components.py..." - python script/test_build_components.py \ - -e compile \ - -c "$component_list" \ - -t "$platform" 2>&1 | \ - python script/ci_memory_impact_extract.py --output-env - - name: Find and upload final ELF file - run: | - # Note: test_build_components.py may run multiple builds, but each overwrites - # the previous firmware.elf. The memory totals (RAM/Flash) are already summed - # by ci_memory_impact_extract.py. This ELF is from the last build and is used - # for detailed component breakdown (if available). - mkdir -p ./elf-artifacts/pr - # Find the most recent firmware.elf - if [ -d ~/.esphome/build ]; then - elf_file=$(find ~/.esphome/build -name "firmware.elf" -type f -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-) + # Find most recent build directory for detailed analysis + build_dir=$(find ~/.esphome/build -type d -maxdepth 1 -mindepth 1 -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2- || echo "") - if [ -n "$elf_file" ] && [ -f "$elf_file" ]; then - echo "Found final ELF file: $elf_file" - cp "$elf_file" "./elf-artifacts/pr/firmware.elf" - else - echo "Warning: No ELF file found in ~/.esphome/build" - ls -la ~/.esphome/build/ || true - fi + # Run build and extract memory, with optional detailed analysis + if [ -n "$build_dir" ]; then + python script/test_build_components.py \ + -e compile \ + -c "$component_list" \ + -t "$platform" 2>&1 | \ + python script/ci_memory_impact_extract.py \ + --output-env \ + --build-dir "$build_dir" \ + --output-json memory-analysis-pr.json else - echo "Warning: ~/.esphome/build directory not found" + python script/test_build_components.py \ + -e compile \ + -c "$component_list" \ + -t "$platform" 2>&1 | \ + python script/ci_memory_impact_extract.py --output-env fi - - name: Upload ELF artifact + - name: Upload memory analysis JSON uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: memory-impact-pr-elf - path: ./elf-artifacts/pr/firmware.elf + name: memory-analysis-pr + path: memory-analysis-pr.json if-no-files-found: warn retention-days: 1 @@ -691,17 +679,17 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Download target ELF artifact + - name: Download target analysis JSON uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: memory-impact-target-elf - path: ./elf-artifacts/target + name: memory-analysis-target + path: ./memory-analysis continue-on-error: true - - name: Download PR ELF artifact + - name: Download PR analysis JSON uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: memory-impact-pr-elf - path: ./elf-artifacts/pr + name: memory-analysis-pr + path: ./memory-analysis continue-on-error: true - name: Post or update PR comment env: @@ -715,22 +703,22 @@ jobs: run: | . venv/bin/activate - # Check if ELF files exist (from final build) - target_elf_arg="" - pr_elf_arg="" + # Check if analysis JSON files exist + target_json_arg="" + pr_json_arg="" - if [ -f ./elf-artifacts/target/firmware.elf ]; then - echo "Found target ELF file" - target_elf_arg="--target-elf ./elf-artifacts/target/firmware.elf" + if [ -f ./memory-analysis/memory-analysis-target.json ]; then + echo "Found target analysis JSON" + target_json_arg="--target-json ./memory-analysis/memory-analysis-target.json" else - echo "No target ELF file found" + echo "No target analysis JSON found" fi - if [ -f ./elf-artifacts/pr/firmware.elf ]; then - echo "Found PR ELF file" - pr_elf_arg="--pr-elf ./elf-artifacts/pr/firmware.elf" + if [ -f ./memory-analysis/memory-analysis-pr.json ]; then + echo "Found PR analysis JSON" + pr_json_arg="--pr-json ./memory-analysis/memory-analysis-pr.json" else - echo "No PR ELF file found" + echo "No PR analysis JSON found" fi python script/ci_memory_impact_comment.py \ @@ -741,8 +729,8 @@ jobs: --target-flash "$TARGET_FLASH" \ --pr-ram "$PR_RAM" \ --pr-flash "$PR_FLASH" \ - $target_elf_arg \ - $pr_elf_arg + $target_json_arg \ + $pr_json_arg ci-status: name: CI Status diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index b76cb4ec3f..5bd46fd01e 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -7,6 +7,7 @@ import logging from pathlib import Path import re import subprocess +from typing import TYPE_CHECKING from .const import ( CORE_SUBCATEGORY_PATTERNS, @@ -22,9 +23,65 @@ from .helpers import ( parse_symbol_line, ) +if TYPE_CHECKING: + from esphome.platformio_api import IDEData + _LOGGER = logging.getLogger(__name__) +def get_toolchain_for_platform(platform: str) -> tuple[str | None, str | None]: + """Get objdump and readelf paths for a given platform. + + This function auto-detects the correct toolchain based on the platform name, + using the same detection logic as PlatformIO's IDEData class. + + Args: + platform: Platform name (e.g., "esp8266-ard", "esp32-idf", "esp32-c3-idf") + + Returns: + Tuple of (objdump_path, readelf_path) or (None, None) if not found/supported + """ + home = Path.home() + platformio_packages = home / ".platformio" / "packages" + + # Map platform to toolchain and prefix (same logic as PlatformIO uses) + toolchain = None + prefix = None + + if "esp8266" in platform: + toolchain = "toolchain-xtensa" + prefix = "xtensa-lx106-elf" + elif "esp32-c" in platform or "esp32-h" in platform or "esp32-p4" in platform: + # RISC-V variants (C2, C3, C5, C6, H2, P4) + toolchain = "toolchain-riscv32-esp" + prefix = "riscv32-esp-elf" + elif "esp32" in platform: + # Xtensa variants (original, S2, S3) + toolchain = "toolchain-xtensa-esp-elf" + if "s2" in platform: + prefix = "xtensa-esp32s2-elf" + elif "s3" in platform: + prefix = "xtensa-esp32s3-elf" + else: + prefix = "xtensa-esp32-elf" + else: + # Other platforms (RP2040, LibreTiny, etc.) - not supported for ELF analysis + _LOGGER.debug("Platform %s not supported for ELF analysis", platform) + return None, None + + # Construct paths (same pattern as IDEData.objdump_path/readelf_path) + toolchain_path = platformio_packages / toolchain / "bin" + objdump_path = toolchain_path / f"{prefix}-objdump" + readelf_path = toolchain_path / f"{prefix}-readelf" + + if objdump_path.exists() and readelf_path.exists(): + _LOGGER.debug("Found %s toolchain: %s", platform, prefix) + return str(objdump_path), str(readelf_path) + + _LOGGER.warning("Toolchain not found at %s", toolchain_path) + return None, None + + @dataclass class MemorySection: """Represents a memory section with its symbols.""" @@ -67,11 +124,27 @@ class MemoryAnalyzer: objdump_path: str | None = None, readelf_path: str | None = None, external_components: set[str] | None = None, + idedata: "IDEData | None" = None, ): + """Initialize memory analyzer. + + Args: + elf_path: Path to ELF file to analyze + objdump_path: Path to objdump binary (auto-detected from idedata if not provided) + readelf_path: Path to readelf binary (auto-detected from idedata if not provided) + external_components: Set of external component names + idedata: Optional PlatformIO IDEData object to auto-detect toolchain paths + """ self.elf_path = Path(elf_path) if not self.elf_path.exists(): raise FileNotFoundError(f"ELF file not found: {elf_path}") + # Auto-detect toolchain paths from idedata if not provided + if idedata is not None and (objdump_path is None or readelf_path is None): + objdump_path = objdump_path or idedata.objdump_path + readelf_path = readelf_path or idedata.readelf_path + _LOGGER.debug("Using toolchain paths from PlatformIO idedata") + self.objdump_path = objdump_path or "objdump" self.readelf_path = readelf_path or "readelf" self.external_components = external_components or set() diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 184f95ffa6..e8541b1621 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -1,7 +1,6 @@ """CLI interface for memory analysis with report generation.""" from collections import defaultdict -import subprocess import sys from . import MemoryAnalyzer @@ -313,51 +312,91 @@ def analyze_elf( def main(): """CLI entrypoint for memory analysis.""" if len(sys.argv) < 2: - print( - "Usage: python -m esphome.analyze_memory [objdump_path] [readelf_path]" - ) - print("\nIf objdump/readelf paths are not provided, you must specify them.") - print("\nExample for ESP8266:") - print(" python -m esphome.analyze_memory firmware.elf \\") - print( - " ~/.platformio/packages/toolchain-xtensa/bin/xtensa-lx106-elf-objdump \\" - ) - print( - " ~/.platformio/packages/toolchain-xtensa/bin/xtensa-lx106-elf-readelf" - ) - print("\nExample for ESP32:") - print(" python -m esphome.analyze_memory firmware.elf \\") - print( - " ~/.platformio/packages/toolchain-xtensa-esp-elf/bin/xtensa-esp32-elf-objdump \\" - ) - print( - " ~/.platformio/packages/toolchain-xtensa-esp-elf/bin/xtensa-esp32-elf-readelf" - ) - print("\nExample for ESP32-C3 (RISC-V):") - print(" python -m esphome.analyze_memory firmware.elf \\") - print( - " ~/.platformio/packages/toolchain-riscv32-esp/bin/riscv32-esp-elf-objdump \\" - ) - print( - " ~/.platformio/packages/toolchain-riscv32-esp/bin/riscv32-esp-elf-readelf" - ) + print("Usage: python -m esphome.analyze_memory ") + print("\nAnalyze memory usage from an ESPHome build directory.") + print("The build directory should contain firmware.elf and idedata will be") + print("loaded from ~/.esphome/.internal/idedata/.json") + print("\nExamples:") + print(" python -m esphome.analyze_memory ~/.esphome/build/my-device") + print(" python -m esphome.analyze_memory .esphome/build/my-device") + print(" python -m esphome.analyze_memory my-device # Short form") sys.exit(1) - elf_file = sys.argv[1] - objdump_path = sys.argv[2] if len(sys.argv) > 2 else None - readelf_path = sys.argv[3] if len(sys.argv) > 3 else None + build_dir = sys.argv[1] + + # Load build directory + import json + from pathlib import Path + + from esphome.platformio_api import IDEData + + build_path = Path(build_dir) + + # If no path separator in name, assume it's a device name + if "/" not in build_dir and not build_path.is_dir(): + # Try current directory first + cwd_path = Path.cwd() / ".esphome" / "build" / build_dir + if cwd_path.is_dir(): + build_path = cwd_path + print(f"Using build directory: {build_path}", file=sys.stderr) + else: + # Fall back to home directory + build_path = Path.home() / ".esphome" / "build" / build_dir + print(f"Using build directory: {build_path}", file=sys.stderr) + + if not build_path.is_dir(): + print(f"Error: {build_path} is not a directory", file=sys.stderr) + sys.exit(1) + + # Find firmware.elf + elf_file = None + for elf_candidate in [ + build_path / "firmware.elf", + build_path / ".pioenvs" / build_path.name / "firmware.elf", + ]: + if elf_candidate.exists(): + elf_file = str(elf_candidate) + break + + if not elf_file: + print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr) + sys.exit(1) + + # Find idedata.json - check current directory first, then home + device_name = build_path.name + idedata_candidates = [ + Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json", + Path.home() / ".esphome" / "idedata" / f"{device_name}.json", + ] + + idedata = None + for idedata_path in idedata_candidates: + if idedata_path.exists(): + try: + with open(idedata_path, encoding="utf-8") as f: + raw_data = json.load(f) + idedata = IDEData(raw_data) + print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) + break + except (json.JSONDecodeError, OSError) as e: + print(f"Warning: Failed to load idedata: {e}", file=sys.stderr) + + if not idedata: + print( + f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})", + file=sys.stderr, + ) try: - report = analyze_elf(elf_file, objdump_path, readelf_path) + analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata) + analyzer.analyze() + report = analyzer.generate_report() print(report) - except (subprocess.CalledProcessError, FileNotFoundError, OSError) as e: + except Exception as e: print(f"Error: {e}", file=sys.stderr) - if "readelf" in str(e) or "objdump" in str(e): - print( - "\nHint: You need to specify the toolchain-specific tools.", - file=sys.stderr, - ) - print("See usage above for examples.", file=sys.stderr) + import traceback + + traceback.print_exc(file=sys.stderr) sys.exit(1) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index a4b5b432fd..065a8cf896 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -412,10 +412,8 @@ def analyze_memory_usage(config: dict[str, Any]) -> None: idedata = get_idedata(config) - # Get paths to tools + # Get ELF path elf_path = idedata.firmware_elf_path - objdump_path = idedata.objdump_path - readelf_path = idedata.readelf_path # Debug logging _LOGGER.debug("ELF path from idedata: %s", elf_path) @@ -457,7 +455,10 @@ def analyze_memory_usage(config: dict[str, Any]) -> None: _LOGGER.debug("Detected external components: %s", external_components) # Create analyzer and run analysis - analyzer = MemoryAnalyzer(elf_path, objdump_path, readelf_path, external_components) + # Pass idedata to auto-detect toolchain paths + analyzer = MemoryAnalyzer( + elf_path, external_components=external_components, idedata=idedata + ) analyzer.analyze() # Generate and print report diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index d31868ed1c..c5eb9e701f 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -18,61 +18,31 @@ import sys sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position -from esphome.analyze_memory import MemoryAnalyzer # noqa: E402 # Comment marker to identify our memory impact comments COMMENT_MARKER = "" -def get_platform_toolchain(platform: str) -> tuple[str | None, str | None]: - """Get platform-specific objdump and readelf paths. +def load_analysis_json(json_path: str) -> dict | None: + """Load memory analysis results from JSON file. Args: - platform: Platform name (e.g., "esp8266-ard", "esp32-idf", "esp32-c3-idf") + json_path: Path to analysis JSON file Returns: - Tuple of (objdump_path, readelf_path) or (None, None) if not found/supported + Dictionary with analysis results or None if file doesn't exist/can't be loaded """ - from pathlib import Path + json_file = Path(json_path) + if not json_file.exists(): + print(f"Analysis JSON not found: {json_path}", file=sys.stderr) + return None - home = Path.home() - platformio_packages = home / ".platformio" / "packages" - - # Map platform to toolchain - toolchain = None - prefix = None - - if "esp8266" in platform: - toolchain = "toolchain-xtensa" - prefix = "xtensa-lx106-elf" - elif "esp32-c" in platform or "esp32-h" in platform or "esp32-p4" in platform: - # RISC-V variants (C2, C3, C5, C6, H2, P4) - toolchain = "toolchain-riscv32-esp" - prefix = "riscv32-esp-elf" - elif "esp32" in platform: - # Xtensa variants (original, S2, S3) - toolchain = "toolchain-xtensa-esp-elf" - if "s2" in platform: - prefix = "xtensa-esp32s2-elf" - elif "s3" in platform: - prefix = "xtensa-esp32s3-elf" - else: - prefix = "xtensa-esp32-elf" - else: - # Other platforms (RP2040, LibreTiny, etc.) - not supported - print(f"Platform {platform} not supported for ELF analysis", file=sys.stderr) - return None, None - - toolchain_path = platformio_packages / toolchain / "bin" - objdump_path = toolchain_path / f"{prefix}-objdump" - readelf_path = toolchain_path / f"{prefix}-readelf" - - if objdump_path.exists() and readelf_path.exists(): - print(f"Using {platform} toolchain: {prefix}", file=sys.stderr) - return str(objdump_path), str(readelf_path) - - print(f"Warning: Toolchain not found at {toolchain_path}", file=sys.stderr) - return None, None + try: + with open(json_file, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + print(f"Failed to load analysis JSON: {e}", file=sys.stderr) + return None def format_bytes(bytes_value: int) -> str: @@ -122,56 +92,6 @@ def format_change(before: int, after: int) -> str: return f"{emoji} {delta_str} ({pct_str})" -def run_detailed_analysis( - elf_path: str, objdump_path: str | None = None, readelf_path: str | None = None -) -> tuple[dict | None, dict | None]: - """Run detailed memory analysis on an ELF file. - - Args: - elf_path: Path to ELF file - objdump_path: Optional path to objdump tool - readelf_path: Optional path to readelf tool - - Returns: - Tuple of (component_breakdown, symbol_map) or (None, None) if analysis fails - component_breakdown: Dictionary with component memory breakdown - symbol_map: Dictionary mapping symbol names to their sizes - """ - try: - analyzer = MemoryAnalyzer(elf_path, objdump_path, readelf_path) - components = analyzer.analyze() - - # Convert ComponentMemory objects to dictionaries - component_result = {} - for name, mem in components.items(): - component_result[name] = { - "text": mem.text_size, - "rodata": mem.rodata_size, - "data": mem.data_size, - "bss": mem.bss_size, - "flash_total": mem.flash_total, - "ram_total": mem.ram_total, - "symbol_count": mem.symbol_count, - } - - # Build symbol map from all sections - symbol_map = {} - for section in analyzer.sections.values(): - for symbol_name, size, _ in section.symbols: - if size > 0: # Only track non-zero sized symbols - # Demangle the symbol for better readability - demangled = analyzer._demangle_symbol(symbol_name) - symbol_map[demangled] = size - - return component_result, symbol_map - except Exception as e: - print(f"Warning: Failed to run detailed analysis: {e}", file=sys.stderr) - import traceback - - traceback.print_exc(file=sys.stderr) - return None, None - - def create_symbol_changes_table( target_symbols: dict | None, pr_symbols: dict | None ) -> str: @@ -371,10 +291,10 @@ def create_comment_body( target_flash: int, pr_ram: int, pr_flash: int, - target_elf: str | None = None, - pr_elf: str | None = None, - objdump_path: str | None = None, - readelf_path: str | None = None, + target_analysis: dict | None = None, + pr_analysis: dict | None = None, + target_symbols: dict | None = None, + pr_symbols: dict | None = None, ) -> str: """Create the comment body with memory impact analysis. @@ -385,10 +305,10 @@ def create_comment_body( target_flash: Flash usage in target branch pr_ram: RAM usage in PR branch pr_flash: Flash usage in PR branch - target_elf: Optional path to target branch ELF file - pr_elf: Optional path to PR branch ELF file - objdump_path: Optional path to objdump tool - readelf_path: Optional path to readelf tool + target_analysis: Optional component breakdown for target branch + pr_analysis: Optional component breakdown for PR branch + target_symbols: Optional symbol map for target branch + pr_symbols: Optional symbol map for PR branch Returns: Formatted comment body @@ -396,29 +316,14 @@ def create_comment_body( ram_change = format_change(target_ram, pr_ram) flash_change = format_change(target_flash, pr_flash) - # Run detailed analysis if ELF files are provided - target_analysis = None - pr_analysis = None - target_symbols = None - pr_symbols = None + # Use provided analysis data if available component_breakdown = "" symbol_changes = "" - if target_elf and pr_elf: - print( - f"Running detailed analysis on {target_elf} and {pr_elf}", file=sys.stderr + if target_analysis and pr_analysis: + component_breakdown = create_detailed_breakdown_table( + target_analysis, pr_analysis ) - target_analysis, target_symbols = run_detailed_analysis( - target_elf, objdump_path, readelf_path - ) - pr_analysis, pr_symbols = run_detailed_analysis( - pr_elf, objdump_path, readelf_path - ) - - if target_analysis and pr_analysis: - component_breakdown = create_detailed_breakdown_table( - target_analysis, pr_analysis - ) if target_symbols and pr_symbols: symbol_changes = create_symbol_changes_table(target_symbols, pr_symbols) @@ -612,13 +517,13 @@ def main() -> int: parser.add_argument( "--pr-flash", type=int, required=True, help="PR branch flash usage" ) - parser.add_argument("--target-elf", help="Optional path to target branch ELF file") - parser.add_argument("--pr-elf", help="Optional path to PR branch ELF file") parser.add_argument( - "--objdump-path", help="Optional path to objdump tool for detailed analysis" + "--target-json", + help="Optional path to target branch analysis JSON (for detailed analysis)", ) parser.add_argument( - "--readelf-path", help="Optional path to readelf tool for detailed analysis" + "--pr-json", + help="Optional path to PR branch analysis JSON (for detailed analysis)", ) args = parser.parse_args() @@ -633,17 +538,26 @@ def main() -> int: print(f"Error parsing --components JSON: {e}", file=sys.stderr) sys.exit(1) - # Detect platform-specific toolchain paths - objdump_path = args.objdump_path - readelf_path = args.readelf_path + # Load analysis JSON files + target_analysis = None + pr_analysis = None + target_symbols = None + pr_symbols = None - if not objdump_path or not readelf_path: - # Auto-detect based on platform - objdump_path, readelf_path = get_platform_toolchain(args.platform) + if args.target_json: + target_data = load_analysis_json(args.target_json) + if target_data and target_data.get("detailed_analysis"): + target_analysis = target_data["detailed_analysis"].get("components") + target_symbols = target_data["detailed_analysis"].get("symbols") + + if args.pr_json: + pr_data = load_analysis_json(args.pr_json) + if pr_data and pr_data.get("detailed_analysis"): + pr_analysis = pr_data["detailed_analysis"].get("components") + pr_symbols = pr_data["detailed_analysis"].get("symbols") # Create comment body - # Note: ELF files (if provided) are from the final build when test_build_components - # runs multiple builds. Memory totals (RAM/Flash) are already summed across all builds. + # Note: Memory totals (RAM/Flash) are summed across all builds if multiple were run. comment_body = create_comment_body( components=components, platform=args.platform, @@ -651,10 +565,10 @@ def main() -> int: target_flash=args.target_flash, pr_ram=args.pr_ram, pr_flash=args.pr_flash, - target_elf=args.target_elf, - pr_elf=args.pr_elf, - objdump_path=objdump_path, - readelf_path=readelf_path, + target_analysis=target_analysis, + pr_analysis=pr_analysis, + target_symbols=target_symbols, + pr_symbols=pr_symbols, ) # Post or update comment diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index 1b8a994f14..283b521860 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -9,11 +9,14 @@ The script reads compile output from stdin and looks for the standard PlatformIO output format: RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) + +Optionally performs detailed memory analysis if a build directory is provided. """ from __future__ import annotations import argparse +import json from pathlib import Path import re import sys @@ -60,6 +63,87 @@ def extract_from_compile_output(output_text: str) -> tuple[int | None, int | Non return total_ram, total_flash +def run_detailed_analysis(build_dir: str) -> dict | None: + """Run detailed memory analysis on build directory. + + Args: + build_dir: Path to ESPHome build directory + + Returns: + Dictionary with analysis results or None if analysis fails + """ + from esphome.analyze_memory import MemoryAnalyzer + from esphome.platformio_api import IDEData + + build_path = Path(build_dir) + if not build_path.exists(): + print(f"Build directory not found: {build_dir}", file=sys.stderr) + return None + + # Find firmware.elf + elf_path = None + for elf_candidate in [ + build_path / "firmware.elf", + build_path / ".pioenvs" / build_path.name / "firmware.elf", + ]: + if elf_candidate.exists(): + elf_path = str(elf_candidate) + break + + if not elf_path: + print(f"firmware.elf not found in {build_dir}", file=sys.stderr) + return None + + # Find idedata.json + device_name = build_path.name + idedata_path = Path.home() / ".esphome" / "idedata" / f"{device_name}.json" + + idedata = None + if idedata_path.exists(): + try: + with open(idedata_path, encoding="utf-8") as f: + raw_data = json.load(f) + idedata = IDEData(raw_data) + except (json.JSONDecodeError, OSError) as e: + print(f"Warning: Failed to load idedata: {e}", file=sys.stderr) + + try: + analyzer = MemoryAnalyzer(elf_path, idedata=idedata) + components = analyzer.analyze() + + # Convert to JSON-serializable format + result = { + "components": {}, + "symbols": {}, + } + + for name, mem in components.items(): + result["components"][name] = { + "text": mem.text_size, + "rodata": mem.rodata_size, + "data": mem.data_size, + "bss": mem.bss_size, + "flash_total": mem.flash_total, + "ram_total": mem.ram_total, + "symbol_count": mem.symbol_count, + } + + # Build symbol map + for section in analyzer.sections.values(): + for symbol_name, size, _ in section.symbols: + if size > 0: + demangled = analyzer._demangle_symbol(symbol_name) + result["symbols"][demangled] = size + + return result + except Exception as e: + print(f"Warning: Failed to run detailed analysis: {e}", file=sys.stderr) + import traceback + + traceback.print_exc(file=sys.stderr) + return None + + def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( @@ -70,6 +154,14 @@ def main() -> int: action="store_true", help="Output to GITHUB_OUTPUT environment file", ) + parser.add_argument( + "--build-dir", + help="Optional build directory for detailed memory analysis", + ) + parser.add_argument( + "--output-json", + help="Optional path to save detailed analysis JSON", + ) args = parser.parse_args() @@ -108,6 +200,26 @@ def main() -> int: print(f"Total RAM: {ram_bytes} bytes", file=sys.stderr) print(f"Total Flash: {flash_bytes} bytes", file=sys.stderr) + # Run detailed analysis if build directory provided + detailed_analysis = None + if args.build_dir: + print(f"Running detailed analysis on {args.build_dir}", file=sys.stderr) + detailed_analysis = run_detailed_analysis(args.build_dir) + + # Save JSON output if requested + if args.output_json: + output_data = { + "ram_bytes": ram_bytes, + "flash_bytes": flash_bytes, + "detailed_analysis": detailed_analysis, + } + + output_path = Path(args.output_json) + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=2) + print(f"Saved analysis to {args.output_json}", file=sys.stderr) + if args.output_env: # Output to GitHub Actions write_github_output(