diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index b1443edac31..dfe2d72b9de 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -89,6 +89,16 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {extra_compile_options} project({CORE.name}) + +# Emit raw JSON size data for ESPHome to read post-build. +add_custom_command( + TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD + COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw + -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json + ${{CMAKE_PROJECT_NAME}}.map + WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} + VERBATIM +) """ diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 34e3e7694b5..65df37c7b24 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -57,6 +57,15 @@ FILTER_IDF_LINES: list[str] = [ # line, so a NOTICE often arrives prefixed with ".NOTICE:" or # "...........NOTICE:". r"\.*NOTICE: ", + # ``idf.py size`` prefaces its table with a centered banner; the + # per-region table below already makes the structure obvious. + r"\s*Memory Type Usage Summary", + # Prefix match for esp-idf-size's trailing "Note:" paragraph (no + # upstream flag suppresses it). + r"Note: The reported total sizes may be smaller than those in the", + # Drop the blank line rich emits after the note so the build log + # doesn't end with an orphan gap before ESPHome's own status lines. + r"\s*$", ] diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py new file mode 100644 index 00000000000..9477e664b34 --- /dev/null +++ b/esphome/espidf/size_summary.py @@ -0,0 +1,111 @@ +"""PlatformIO-format RAM/Flash one-liners after a native ESP-IDF build. + +``idf.py size`` (chained onto ``idf.py build`` in +``toolchain.run_compile``) prints the per-region table inline as part +of the build. This module adds two summary lines underneath, +byte-identical to PlatformIO's output: + + RAM: [==== ] 26.5% (used 47932 bytes from 180736 bytes) + Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes) + +The format matches ``script/ci_memory_impact_extract.py`` so CI memory +analysis works unchanged on native ESP-IDF builds. RAM total is the +DRAM region size from the linker map; Flash total is taken from +``partitions.csv`` using PlatformIO's rule (first app partition whose +subtype is ``factory`` or ``ota_0``; see +``platform-espressif32/builder/main.py::_update_max_upload_size``). + +Structured size data is produced at link time by a CMake POST_BUILD +custom command (see ``build_gen/espidf.py``) which writes +``esp_idf_size.json`` next to the ELF. We read that file here rather +than re-running ``esp_idf_size`` from Python. +""" + +from __future__ import annotations + +import csv +import json +import logging +from pathlib import Path + +_LOGGER = logging.getLogger(__name__) +_SIZE_SUFFIXES = {"K": 1024, "M": 1024 * 1024} + + +def _parse_size(token: str) -> int: + token = token.strip() + if not token: + return 0 + if token.startswith(("0x", "0X")): + return int(token, 16) + suffix = token[-1].upper() + if suffix in _SIZE_SUFFIXES: + return int(token[:-1]) * _SIZE_SUFFIXES[suffix] + return int(token) + + +def _find_app_partition_size(partitions_csv: Path) -> int: + """Return the size of the firmware's app partition. + + Mirrors PlatformIO's ``platform-espressif32/builder/main.py:: + _update_max_upload_size``: take the first ``app``-type partition + whose subtype is ``factory`` or ``ota_0``. Order matters because + layouts like Adafruit's ``partitions-4MB-tinyuf2.csv`` repurpose + ``factory`` for a UF2 bootloader before the real OTA slot, so a + naive "prefer factory" rule would pick the wrong row. Raises + ``ValueError`` if no qualifying partition is present. + """ + if not partitions_csv.is_file(): + raise ValueError(f"partitions.csv not found at {partitions_csv}") + for row in csv.reader(partitions_csv.read_text().splitlines()): + cells = [c.strip() for c in row] + if not cells or cells[0].startswith("#") or len(cells) < 5: + continue + ptype, psubtype, psize = cells[1], cells[2], cells[4] + if ptype in ("app", "0") and psubtype in ("factory", "ota_0"): + return _parse_size(psize) + raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}") + + +def _format_bar(used: int, total: int) -> str: + """Match PlatformIO's ``_format_availale_bytes`` (pioupload.py) exactly.""" + pct_raw = used / total if total else 0 + blocks = 10 + filled = min(int(round(blocks * pct_raw)), blocks) + progress = "=" * filled + return ( + f"[{progress:<{blocks}}] {pct_raw: 6.1%} " + f"(used {used:d} bytes from {total:d} bytes)" + ) + + +def print_summary(size_json: Path, partitions_csv: Path | None) -> None: + """Print PlatformIO-shaped RAM and Flash one-liners. + + Failures are non-fatal: the build has already succeeded, we just couldn't + summarize. Logs the cause at debug level. + """ + if not size_json.is_file(): + _LOGGER.debug("Skipping size summary: %s not found", size_json) + return + try: + data = json.loads(size_json.read_text()) + except (OSError, json.JSONDecodeError) as e: + _LOGGER.debug("Skipping size summary: %s", e) + return + + dram = data.get("memory_types", {}).get("DRAM") or {} + ram_used = dram.get("used") + ram_total = dram.get("size") + if ram_total and ram_used is not None: + print(f"RAM: {_format_bar(ram_used, ram_total)}") + + image_size = data.get("image_size") + if image_size is None or partitions_csv is None: + return + try: + app_size = _find_app_partition_size(partitions_csv) + except ValueError as e: + _LOGGER.debug("Skipping Flash summary: %s", e) + return + print(f"Flash: {_format_bar(image_size, app_size)}") diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index da6d3a8a37f..ecb759ed10c 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -12,6 +12,7 @@ import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env +from esphome.espidf.size_summary import print_summary _LOGGER = logging.getLogger(__name__) @@ -341,8 +342,14 @@ def run_compile(config, verbose: bool) -> int: args.extend(_get_sdkconfig_args()) args.append("build") + args.append("size") - return run_idf_py(*args) + rc = run_idf_py(*args) + if rc == 0: + size_json = CORE.relative_build_path("build", "esp_idf_size.json") + partitions = CORE.relative_build_path("partitions.csv") + print_summary(size_json, partitions if partitions.is_file() else None) + return rc def get_firmware_path() -> Path: