1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-21 19:23:45 +01:00
This commit is contained in:
J. Nick Koston
2025-10-17 14:40:45 -10:00
parent f011c44130
commit f87c969b43
6 changed files with 381 additions and 254 deletions

View File

@@ -548,7 +548,7 @@ jobs:
with: with:
path: ~/.platformio path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} 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 id: extract
run: | run: |
. venv/bin/activate . venv/bin/activate
@@ -563,38 +563,32 @@ jobs:
component_list=$(echo "$components" | jq -r 'join(",")') component_list=$(echo "$components" | jq -r 'join(",")')
echo "Compiling with test_build_components.py..." echo "Compiling with test_build_components.py..."
# 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 "")
# 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
python script/test_build_components.py \ python script/test_build_components.py \
-e compile \ -e compile \
-c "$component_list" \ -c "$component_list" \
-t "$platform" 2>&1 | \ -t "$platform" 2>&1 | \
python script/ci_memory_impact_extract.py --output-env 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-)
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 fi
else - name: Upload memory analysis JSON
echo "Warning: ~/.esphome/build directory not found"
fi
- name: Upload ELF artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: memory-impact-target-elf name: memory-analysis-target
path: ./elf-artifacts/target/firmware.elf path: memory-analysis-target.json
if-no-files-found: warn if-no-files-found: warn
retention-days: 1 retention-days: 1
@@ -621,7 +615,7 @@ jobs:
with: with:
path: ~/.platformio path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} 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 id: extract
run: | run: |
. venv/bin/activate . venv/bin/activate
@@ -636,38 +630,32 @@ jobs:
component_list=$(echo "$components" | jq -r 'join(",")') component_list=$(echo "$components" | jq -r 'join(",")')
echo "Compiling with test_build_components.py..." echo "Compiling with test_build_components.py..."
# 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 "")
# 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
python script/test_build_components.py \ python script/test_build_components.py \
-e compile \ -e compile \
-c "$component_list" \ -c "$component_list" \
-t "$platform" 2>&1 | \ -t "$platform" 2>&1 | \
python script/ci_memory_impact_extract.py --output-env 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-)
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 fi
else - name: Upload memory analysis JSON
echo "Warning: ~/.esphome/build directory not found"
fi
- name: Upload ELF artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: memory-impact-pr-elf name: memory-analysis-pr
path: ./elf-artifacts/pr/firmware.elf path: memory-analysis-pr.json
if-no-files-found: warn if-no-files-found: warn
retention-days: 1 retention-days: 1
@@ -691,17 +679,17 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }} 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 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with: with:
name: memory-impact-target-elf name: memory-analysis-target
path: ./elf-artifacts/target path: ./memory-analysis
continue-on-error: true continue-on-error: true
- name: Download PR ELF artifact - name: Download PR analysis JSON
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with: with:
name: memory-impact-pr-elf name: memory-analysis-pr
path: ./elf-artifacts/pr path: ./memory-analysis
continue-on-error: true continue-on-error: true
- name: Post or update PR comment - name: Post or update PR comment
env: env:
@@ -715,22 +703,22 @@ jobs:
run: | run: |
. venv/bin/activate . venv/bin/activate
# Check if ELF files exist (from final build) # Check if analysis JSON files exist
target_elf_arg="" target_json_arg=""
pr_elf_arg="" pr_json_arg=""
if [ -f ./elf-artifacts/target/firmware.elf ]; then if [ -f ./memory-analysis/memory-analysis-target.json ]; then
echo "Found target ELF file" echo "Found target analysis JSON"
target_elf_arg="--target-elf ./elf-artifacts/target/firmware.elf" target_json_arg="--target-json ./memory-analysis/memory-analysis-target.json"
else else
echo "No target ELF file found" echo "No target analysis JSON found"
fi fi
if [ -f ./elf-artifacts/pr/firmware.elf ]; then if [ -f ./memory-analysis/memory-analysis-pr.json ]; then
echo "Found PR ELF file" echo "Found PR analysis JSON"
pr_elf_arg="--pr-elf ./elf-artifacts/pr/firmware.elf" pr_json_arg="--pr-json ./memory-analysis/memory-analysis-pr.json"
else else
echo "No PR ELF file found" echo "No PR analysis JSON found"
fi fi
python script/ci_memory_impact_comment.py \ python script/ci_memory_impact_comment.py \
@@ -741,8 +729,8 @@ jobs:
--target-flash "$TARGET_FLASH" \ --target-flash "$TARGET_FLASH" \
--pr-ram "$PR_RAM" \ --pr-ram "$PR_RAM" \
--pr-flash "$PR_FLASH" \ --pr-flash "$PR_FLASH" \
$target_elf_arg \ $target_json_arg \
$pr_elf_arg $pr_json_arg
ci-status: ci-status:
name: CI Status name: CI Status

View File

@@ -7,6 +7,7 @@ import logging
from pathlib import Path from pathlib import Path
import re import re
import subprocess import subprocess
from typing import TYPE_CHECKING
from .const import ( from .const import (
CORE_SUBCATEGORY_PATTERNS, CORE_SUBCATEGORY_PATTERNS,
@@ -22,9 +23,65 @@ from .helpers import (
parse_symbol_line, parse_symbol_line,
) )
if TYPE_CHECKING:
from esphome.platformio_api import IDEData
_LOGGER = logging.getLogger(__name__) _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 @dataclass
class MemorySection: class MemorySection:
"""Represents a memory section with its symbols.""" """Represents a memory section with its symbols."""
@@ -67,11 +124,27 @@ class MemoryAnalyzer:
objdump_path: str | None = None, objdump_path: str | None = None,
readelf_path: str | None = None, readelf_path: str | None = None,
external_components: set[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) self.elf_path = Path(elf_path)
if not self.elf_path.exists(): if not self.elf_path.exists():
raise FileNotFoundError(f"ELF file not found: {elf_path}") 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.objdump_path = objdump_path or "objdump"
self.readelf_path = readelf_path or "readelf" self.readelf_path = readelf_path or "readelf"
self.external_components = external_components or set() self.external_components = external_components or set()

View File

@@ -1,7 +1,6 @@
"""CLI interface for memory analysis with report generation.""" """CLI interface for memory analysis with report generation."""
from collections import defaultdict from collections import defaultdict
import subprocess
import sys import sys
from . import MemoryAnalyzer from . import MemoryAnalyzer
@@ -313,51 +312,91 @@ def analyze_elf(
def main(): def main():
"""CLI entrypoint for memory analysis.""" """CLI entrypoint for memory analysis."""
if len(sys.argv) < 2: if len(sys.argv) < 2:
print( print("Usage: python -m esphome.analyze_memory <build_directory>")
"Usage: python -m esphome.analyze_memory <elf_file> [objdump_path] [readelf_path]" print("\nAnalyze memory usage from an ESPHome build directory.")
) print("The build directory should contain firmware.elf and idedata will be")
print("\nIf objdump/readelf paths are not provided, you must specify them.") print("loaded from ~/.esphome/.internal/idedata/<device>.json")
print("\nExample for ESP8266:") print("\nExamples:")
print(" python -m esphome.analyze_memory firmware.elf \\") print(" python -m esphome.analyze_memory ~/.esphome/build/my-device")
print( print(" python -m esphome.analyze_memory .esphome/build/my-device")
" ~/.platformio/packages/toolchain-xtensa/bin/xtensa-lx106-elf-objdump \\" print(" python -m esphome.analyze_memory my-device # Short form")
)
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"
)
sys.exit(1) sys.exit(1)
elf_file = sys.argv[1] build_dir = 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
# 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: try:
report = analyze_elf(elf_file, objdump_path, readelf_path) with open(idedata_path, encoding="utf-8") as f:
print(report) raw_data = json.load(f)
except (subprocess.CalledProcessError, FileNotFoundError, OSError) as e: idedata = IDEData(raw_data)
print(f"Error: {e}", file=sys.stderr) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
if "readelf" in str(e) or "objdump" in str(e): break
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
if not idedata:
print( print(
"\nHint: You need to specify the toolchain-specific tools.", f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
file=sys.stderr, file=sys.stderr,
) )
print("See usage above for examples.", file=sys.stderr)
try:
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
analyzer.analyze()
report = analyzer.generate_report()
print(report)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
sys.exit(1) sys.exit(1)

View File

@@ -412,10 +412,8 @@ def analyze_memory_usage(config: dict[str, Any]) -> None:
idedata = get_idedata(config) idedata = get_idedata(config)
# Get paths to tools # Get ELF path
elf_path = idedata.firmware_elf_path elf_path = idedata.firmware_elf_path
objdump_path = idedata.objdump_path
readelf_path = idedata.readelf_path
# Debug logging # Debug logging
_LOGGER.debug("ELF path from idedata: %s", elf_path) _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) _LOGGER.debug("Detected external components: %s", external_components)
# Create analyzer and run analysis # 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() analyzer.analyze()
# Generate and print report # Generate and print report

View File

@@ -18,61 +18,31 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
# pylint: disable=wrong-import-position # pylint: disable=wrong-import-position
from esphome.analyze_memory import MemoryAnalyzer # noqa: E402
# Comment marker to identify our memory impact comments # Comment marker to identify our memory impact comments
COMMENT_MARKER = "<!-- esphome-memory-impact-analysis -->" COMMENT_MARKER = "<!-- esphome-memory-impact-analysis -->"
def get_platform_toolchain(platform: str) -> tuple[str | None, str | None]: def load_analysis_json(json_path: str) -> dict | None:
"""Get platform-specific objdump and readelf paths. """Load memory analysis results from JSON file.
Args: Args:
platform: Platform name (e.g., "esp8266-ard", "esp32-idf", "esp32-c3-idf") json_path: Path to analysis JSON file
Returns: 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() try:
platformio_packages = home / ".platformio" / "packages" with open(json_file, encoding="utf-8") as f:
return json.load(f)
# Map platform to toolchain except (json.JSONDecodeError, OSError) as e:
toolchain = None print(f"Failed to load analysis JSON: {e}", file=sys.stderr)
prefix = None return 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
def format_bytes(bytes_value: int) -> str: 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})" 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( def create_symbol_changes_table(
target_symbols: dict | None, pr_symbols: dict | None target_symbols: dict | None, pr_symbols: dict | None
) -> str: ) -> str:
@@ -371,10 +291,10 @@ def create_comment_body(
target_flash: int, target_flash: int,
pr_ram: int, pr_ram: int,
pr_flash: int, pr_flash: int,
target_elf: str | None = None, target_analysis: dict | None = None,
pr_elf: str | None = None, pr_analysis: dict | None = None,
objdump_path: str | None = None, target_symbols: dict | None = None,
readelf_path: str | None = None, pr_symbols: dict | None = None,
) -> str: ) -> str:
"""Create the comment body with memory impact analysis. """Create the comment body with memory impact analysis.
@@ -385,10 +305,10 @@ def create_comment_body(
target_flash: Flash usage in target branch target_flash: Flash usage in target branch
pr_ram: RAM usage in PR branch pr_ram: RAM usage in PR branch
pr_flash: Flash usage in PR branch pr_flash: Flash usage in PR branch
target_elf: Optional path to target branch ELF file target_analysis: Optional component breakdown for target branch
pr_elf: Optional path to PR branch ELF file pr_analysis: Optional component breakdown for PR branch
objdump_path: Optional path to objdump tool target_symbols: Optional symbol map for target branch
readelf_path: Optional path to readelf tool pr_symbols: Optional symbol map for PR branch
Returns: Returns:
Formatted comment body Formatted comment body
@@ -396,25 +316,10 @@ def create_comment_body(
ram_change = format_change(target_ram, pr_ram) ram_change = format_change(target_ram, pr_ram)
flash_change = format_change(target_flash, pr_flash) flash_change = format_change(target_flash, pr_flash)
# Run detailed analysis if ELF files are provided # Use provided analysis data if available
target_analysis = None
pr_analysis = None
target_symbols = None
pr_symbols = None
component_breakdown = "" component_breakdown = ""
symbol_changes = "" symbol_changes = ""
if target_elf and pr_elf:
print(
f"Running detailed analysis on {target_elf} and {pr_elf}", file=sys.stderr
)
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: if target_analysis and pr_analysis:
component_breakdown = create_detailed_breakdown_table( component_breakdown = create_detailed_breakdown_table(
target_analysis, pr_analysis target_analysis, pr_analysis
@@ -612,13 +517,13 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--pr-flash", type=int, required=True, help="PR branch flash usage" "--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( 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( 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() args = parser.parse_args()
@@ -633,17 +538,26 @@ def main() -> int:
print(f"Error parsing --components JSON: {e}", file=sys.stderr) print(f"Error parsing --components JSON: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
# Detect platform-specific toolchain paths # Load analysis JSON files
objdump_path = args.objdump_path target_analysis = None
readelf_path = args.readelf_path pr_analysis = None
target_symbols = None
pr_symbols = None
if not objdump_path or not readelf_path: if args.target_json:
# Auto-detect based on platform target_data = load_analysis_json(args.target_json)
objdump_path, readelf_path = get_platform_toolchain(args.platform) 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 # Create comment body
# Note: ELF files (if provided) are from the final build when test_build_components # Note: Memory totals (RAM/Flash) are summed across all builds if multiple were run.
# runs multiple builds. Memory totals (RAM/Flash) are already summed across all builds.
comment_body = create_comment_body( comment_body = create_comment_body(
components=components, components=components,
platform=args.platform, platform=args.platform,
@@ -651,10 +565,10 @@ def main() -> int:
target_flash=args.target_flash, target_flash=args.target_flash,
pr_ram=args.pr_ram, pr_ram=args.pr_ram,
pr_flash=args.pr_flash, pr_flash=args.pr_flash,
target_elf=args.target_elf, target_analysis=target_analysis,
pr_elf=args.pr_elf, pr_analysis=pr_analysis,
objdump_path=objdump_path, target_symbols=target_symbols,
readelf_path=readelf_path, pr_symbols=pr_symbols,
) )
# Post or update comment # Post or update comment

View File

@@ -9,11 +9,14 @@ The script reads compile output from stdin and looks for the standard
PlatformIO output format: PlatformIO output format:
RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)
Flash: [=== ] 34.0% (used 348511 bytes from 1023984 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 from __future__ import annotations
import argparse import argparse
import json
from pathlib import Path from pathlib import Path
import re import re
import sys import sys
@@ -60,6 +63,87 @@ def extract_from_compile_output(output_text: str) -> tuple[int | None, int | Non
return total_ram, total_flash 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: def main() -> int:
"""Main entry point.""" """Main entry point."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -70,6 +154,14 @@ def main() -> int:
action="store_true", action="store_true",
help="Output to GITHUB_OUTPUT environment file", 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() args = parser.parse_args()
@@ -108,6 +200,26 @@ def main() -> int:
print(f"Total RAM: {ram_bytes} bytes", file=sys.stderr) print(f"Total RAM: {ram_bytes} bytes", file=sys.stderr)
print(f"Total Flash: {flash_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: if args.output_env:
# Output to GitHub Actions # Output to GitHub Actions
write_github_output( write_github_output(