Compare commits

..
7 changed files with 403 additions and 378 deletions
+1
View File
@@ -219,5 +219,6 @@ jobs:
run: |
docker run --rm \
-v "${{ github.workspace }}/docker/test_configs:/config" \
-e ESPHOME_LDGEN_STRICT=1 \
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
compile "${{ matrix.id }}.yaml"
+66 -9
View File
@@ -18,7 +18,7 @@ from esphome.framework_helpers import (
get_project_cxx_compile_flags,
get_project_link_flags,
)
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
_LOGGER = logging.getLogger(__name__)
@@ -33,6 +33,46 @@ list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")
list(APPEND esphome_cxx_compile_options "-std={standard}")
idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"""
# Drops the app archive from ldgen's inputs so app-only edits skip the
# sections.ld regeneration. Safe: no mapping fragment references it
# (run_compile re-checks each build). Filters only the top-level call;
# the prior definition stays reachable with an underscore prefix.
_LDGEN_OVERRIDE = """\
if(COMMAND __ldgen_get_lib_deps_of_target)
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_ARMED 1)
function(__ldgen_get_lib_deps_of_target target out_list_var)
if(NOT COMMAND ___ldgen_get_lib_deps_of_target)
message(FATAL_ERROR "ESPHome ldgen override lost the original "
"implementation; set ESPHOME_LDGEN_FULL_DEPS=1 and rebuild.")
endif()
___ldgen_get_lib_deps_of_target(${target} ${out_list_var})
if(out_list_var STREQUAL "ldgen_libraries")
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED 1)
list(LENGTH ${out_list_var} esphome_ldgen_before)
list(REMOVE_ITEM ${out_list_var} idf::src __idf_src)
list(LENGTH ${out_list_var} esphome_ldgen_after)
if(esphome_ldgen_before EQUAL esphome_ldgen_after)
message(@SEVERITY@ "ESPHome ldgen app archive exclusion matched "
"nothing; app edits will regenerate sections.ld.")
endif()
endif()
set(${out_list_var} "${${out_list_var}}" PARENT_SCOPE)
endfunction()
else()
message(@MISSING@ "ESPHome ldgen override target not found; "
"app edits will regenerate sections.ld.")
endif()"""
# Runs after project() so the walk has happened; catches the remaining
# silent path where the top-level out-var was renamed.
_LDGEN_OVERRIDE_CHECK = """\
get_property(esphome_ldgen_armed GLOBAL PROPERTY ESPHOME_LDGEN_ARMED)
get_property(esphome_ldgen_filtered GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED)
if(esphome_ldgen_armed AND NOT esphome_ldgen_filtered)
message(@SEVERITY@ "ESPHome ldgen override never filtered the app "
"archive; app edits will regenerate sections.ld.")
endif()"""
def get_available_components() -> list[str] | None:
"""List the built-in ESP-IDF components from ``project_description.json``.
@@ -90,10 +130,9 @@ def get_project_cmakelists(
"""
idf_target = variant_to_idf_target(get_esp32_variant())
# esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng;
# 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks
# total_size, hence the ELF fallback in espidf/size_summary.py; both
# go away together when 1.x support is dropped.
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
# --format=raw because the legacy mode doesn't support it.
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
# Project-wide compile options: -D defines and -W warning flags (skip
@@ -123,6 +162,22 @@ def get_project_cmakelists(
else ""
)
# Stops the ~3s sections.ld regeneration on app-only edits; see
# _LDGEN_OVERRIDE. ESPHOME_LDGEN_FULL_DEPS=1 restores stock behavior;
# ESPHOME_LDGEN_STRICT=1 (CI) fails the configure when an IDF bump
# breaks the override instead of degrading to stock deps.
if get_bool_env("ESPHOME_LDGEN_FULL_DEPS"):
ldgen_override = ""
ldgen_override_check = ""
else:
strict = get_bool_env("ESPHOME_LDGEN_STRICT")
severity = "FATAL_ERROR" if strict else "WARNING"
missing = "FATAL_ERROR" if strict else "STATUS"
ldgen_override = _LDGEN_OVERRIDE.replace("@SEVERITY@", severity).replace(
"@MISSING@", missing
)
ldgen_override_check = _LDGEN_OVERRIDE_CHECK.replace("@SEVERITY@", severity)
# CMake variables registered via cg.add_cmake_arg(). Emitted before
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
# set when project.cmake seeds the component list, and on minimal
@@ -200,6 +255,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{ldgen_override}
{cpp_standard_options}
{cxx_compile_options}
@@ -212,12 +269,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
project({CORE.name})
# Emit per-memory-type JSON size data for ESPHome to read post-build.
# json2 stays small; raw dumps every symbol (~2s on a large map) and
# this command runs inside the link edge, blocking everything downstream.
{ldgen_override_check}
# 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 {size_ng_flag} --format=json2
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
+16 -81
View File
@@ -9,19 +9,16 @@ byte-identical to PlatformIO's output:
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 usage comes from
the DRAM (or unified DIRAM) region of the linker map. Flash used is the
exact image size matching the ``Total image size`` line: json2
``total_size`` when present, otherwise derived from the ELF (see
``_image_size_from_elf``). Flash total is taken from
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`` (``--format=json2``, a per-memory-type summary)
next to the ELF; we read that rather than re-running ``esp_idf_size``.
``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
@@ -30,7 +27,6 @@ import csv
import json
import logging
from pathlib import Path
import struct
from esphome.build_helpers.size_summary import print_size_line
@@ -73,43 +69,11 @@ def _find_app_partition_size(partitions_csv: Path) -> int:
raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}")
def _image_size_from_elf(elf: Path) -> int:
"""Sum the allocated PROGBITS section sizes from an ELF32 file.
Matches ``esp_idf_size.ng.memorymap._get_image_size`` byte for byte;
esptool's ``ELFFile`` filters sections differently and would not.
Raises ``ValueError`` for anything but a well-formed ELF32 LE file.
"""
with elf.open("rb") as f:
header = f.read(52) # ELF32 header
if len(header) < 52 or header[:6] != b"\x7fELF\x01\x01":
raise ValueError(f"{elf} is not a 32-bit little-endian ELF")
(e_shoff,) = struct.unpack_from("<I", header, 0x20) # e_shoff
e_shentsize, e_shnum = struct.unpack_from("<HH", header, 0x2E)
if e_shentsize < 40: # sizeof(Elf32_Shdr)
raise ValueError(f"{elf} has an invalid section header size")
f.seek(e_shoff)
table = f.read(e_shnum * e_shentsize)
if len(table) < e_shnum * e_shentsize:
raise ValueError(f"{elf} has a truncated section header table")
total = 0
for off in range(0, e_shnum * e_shentsize, e_shentsize):
sh_type, sh_flags = struct.unpack_from("<II", table, off + 4)
(sh_size,) = struct.unpack_from("<I", table, off + 20)
if sh_type == 1 and sh_flags & 0x2: # SHT_PROGBITS with SHF_ALLOC
total += sh_size
if total == 0:
# A used-0-bytes Flash line would read as a real measurement
raise ValueError(f"{elf} has no allocated PROGBITS sections")
return total
def print_summary(size_json: Path, partitions_csv: Path, firmware_elf: Path) -> None:
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. Anomalies (missing region, unreadable ELF) warn; expected
optional inputs (no size json, no partitions.csv) log at debug.
summarize. Logs the cause at debug level.
"""
if not size_json.is_file():
_LOGGER.debug("Skipping size summary: %s not found", size_json)
@@ -119,49 +83,20 @@ def print_summary(size_json: Path, partitions_csv: Path, firmware_elf: Path) ->
except (OSError, json.JSONDecodeError) as e:
_LOGGER.debug("Skipping size summary: %s", e)
return
if not isinstance(data, dict):
_LOGGER.warning("Skipping size summary: unexpected json shape in %s", size_json)
return
layout = data.get("layout")
regions = {
entry.get("name"): entry
for entry in (layout if isinstance(layout, list) else [])
if isinstance(entry, dict)
}
# Every chip has a DRAM or DIRAM region, so a warning here usually
# means the esp_idf_size json schema changed
ram_region = regions.get("DRAM") or regions.get("DIRAM")
if ram_region is None:
_LOGGER.warning("Skipping RAM summary: no DRAM/DIRAM region in %s", size_json)
elif (
isinstance(ram_total := ram_region.get("total"), int)
and ram_total > 0
and isinstance(ram_used := ram_region.get("used"), int)
):
memory_types = data.get("memory_types", {})
ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {}
ram_used = ram_region.get("used")
ram_total = ram_region.get("size")
if ram_total and ram_used is not None:
print_size_line("RAM", ram_used, ram_total)
else:
_LOGGER.warning(
"Skipping RAM summary: unusable region %s in %s", ram_region, size_json
)
# esp-idf-size >= 2.1 (IDF >= 6.0) reports the exact image size in
# json2; older 1.x omits it, so derive the same figure from the ELF.
flash_used = data.get("total_size")
if not (isinstance(flash_used, int) and flash_used > 0):
_LOGGER.debug("No total_size in %s, deriving from %s", size_json, firmware_elf)
try:
flash_used = _image_size_from_elf(firmware_elf)
except (OSError, ValueError) as e:
# The ELF must be present and well formed after a successful build
_LOGGER.warning("Skipping Flash summary: %s", e)
return
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 (OSError, ValueError) as e:
except ValueError as e:
_LOGGER.debug("Skipping Flash summary: %s", e)
return
if app_size <= 0:
_LOGGER.debug("Skipping Flash summary: app partition size is 0")
return
print_size_line("Flash", flash_used, app_size)
print_size_line("Flash", image_size, app_size)
+67 -13
View File
@@ -1,6 +1,7 @@
"""ESP-IDF direct build API for ESPHome."""
from dataclasses import dataclass, field
import fnmatch
import hashlib
import json
import logging
@@ -24,7 +25,7 @@ from esphome.core import CORE, EsphomeError
from esphome.espidf import variant_to_idf_target
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
from esphome.espidf.size_summary import print_summary
from esphome.helpers import add_git_ceiling_directory, write_file
from esphome.helpers import add_git_ceiling_directory, get_bool_env, write_file
_LOGGER = logging.getLogger(__name__)
@@ -479,6 +480,65 @@ def _patch_memory_segments():
_LOGGER.warning("Could not patch memory segments in %s", memory_ld)
_LDGEN_FRAGMENTS_RE = re.compile(r'--fragments-list\s+"([^"]+)"')
_LDGEN_ARCHIVE_RE = re.compile(r"^\s*archive:\s*(\S+)", re.MULTILINE)
def _fragment_maps_app_archive(text: str) -> bool:
"""True when an archive: spec selects libsrc.a, the archive of the src
component excluded as idf::src/__idf_src in build_gen/espidf.py.
The bare * is IDF's stock catch-all; its archive-level entries resolve
in the linker against all link inputs, so it stays safe when the
archive is excluded from ldgen's own inputs.
"""
return any(
value != "*" and fnmatch.fnmatch("libsrc.a", value)
for value in _LDGEN_ARCHIVE_RE.findall(text)
)
def _ldgen_check_skip(msg: str, strict: bool) -> None:
"""A skipped fragment check is debug for users, fatal under strict."""
if strict:
raise EsphomeError(f"ldgen fragment check: {msg} (ESPHOME_LDGEN_STRICT)")
_LOGGER.debug("Skipping ldgen fragment check: %s", msg)
def _warn_if_app_archive_mapped() -> None:
"""Belt for the ldgen exclusion (see build_gen/espidf.py): warn if any
linker fragment names the app archive, since ldgen would silently skip
remapping it rather than fail.
"""
strict = get_bool_env("ESPHOME_LDGEN_STRICT")
build_ninja = CORE.relative_build_path("build", "build.ninja")
try:
ninja_text = build_ninja.read_text(encoding="utf-8", errors="replace")
except OSError as e:
_ldgen_check_skip(f"could not read {build_ninja}: {e}", strict)
return
match = _LDGEN_FRAGMENTS_RE.search(ninja_text)
if match is None:
_ldgen_check_skip(f"no --fragments-list in {build_ninja}", strict)
return
for fragment in match.group(1).split(";"):
try:
text = Path(fragment).read_text(encoding="utf-8", errors="replace")
except OSError as e:
_ldgen_check_skip(f"could not read {fragment}: {e}", strict)
continue
if _fragment_maps_app_archive(text):
msg = (
f"Linker fragment {fragment} maps the app archive; its "
"entries may be skipped. Set ESPHOME_LDGEN_FULL_DEPS=1 "
"and rebuild."
)
if strict:
raise EsphomeError(msg)
_LOGGER.warning("%s", msg)
return
def run_compile(config, verbose: bool) -> int:
"""Compile the ESP-IDF project.
@@ -505,6 +565,9 @@ def run_compile(config, verbose: bool) -> int:
if path.is_file():
os.utime(path)
if not get_bool_env("ESPHOME_LDGEN_FULL_DEPS"):
_warn_if_app_archive_mapped()
# In testing mode, generate the linker script first, patch DRAM/IRAM sizes,
# then build. memory.ld is regenerated by ninja during the build phase,
# so we must patch after it's generated but before linking (same timing
@@ -542,7 +605,7 @@ def run_compile(config, verbose: bool) -> int:
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, get_built_elf_path())
print_summary(size_json, partitions if partitions.is_file() else None)
return rc
@@ -579,16 +642,6 @@ def get_ota_firmware_path() -> Path:
return build_dir / "firmware.ota.bin"
def get_built_elf_path() -> Path:
"""Path to the ELF idf.py writes directly, ``<build>/<name>.elf``.
Exists as soon as the build finishes, unlike the ``firmware.elf``
copy that ``create_elf_copy`` makes later.
"""
build_dir = CORE.relative_build_path("build")
return build_dir / f"{CORE.name}.elf"
def get_elf_path() -> Path:
"""Get the path to the firmware ELF file.
@@ -716,7 +769,8 @@ def create_elf_copy() -> bool:
"download ELF" link requests the literal filename ``firmware.elf``
(PlatformIO convention), so copy it to that name.
"""
src_elf = get_built_elf_path()
build_dir = CORE.relative_build_path("build")
src_elf = build_dir / f"{CORE.name}.elf"
dst_elf = get_elf_path()
if not src_elf.is_file():
+43 -8
View File
@@ -163,16 +163,51 @@ def test_has_discovered_components_after_configure(tmp_path: Path) -> None:
assert has_discovered_components()
def test_get_project_cmakelists_size_command_uses_json2() -> None:
"""The POST_BUILD size command uses the cheap json2 format, with --ng
only on the 1.x tool bundled with IDF < 6."""
content = _render()
assert "-m esp_idf_size --ng --format=json2" in content
@pytest.mark.parametrize("minimal", [False, True])
def test_get_project_cmakelists_emits_ldgen_override(
minimal: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Both renders override the ldgen dep walker to drop the app archive,
after include(project.cmake) which defines the original."""
monkeypatch.delenv("ESPHOME_LDGEN_FULL_DEPS", raising=False)
monkeypatch.delenv("ESPHOME_LDGEN_STRICT", raising=False)
content = _render(minimal=minimal)
assert "REMOVE_ITEM ${out_list_var} idf::src __idf_src" in content
# Quoted so spaced elements survive and an empty list stays defined
assert 'set(${out_list_var} "${${out_list_var}}" PARENT_SCOPE)' in content
assert 'message(WARNING "ESPHome ldgen app archive exclusion' in content
assert 'message(STATUS "ESPHome ldgen override target not found' in content
assert 'message(WARNING "ESPHome ldgen override never filtered' in content
assert content.index("tools/cmake/project.cmake") < content.index(
"function(__ldgen_get_lib_deps_of_target"
)
# The never-filtered check must run after project() has walked the deps
assert content.index("project(test)") < content.index("esphome_ldgen_armed GLOBAL")
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = cv.Version(6, 0, 0)
def test_get_project_cmakelists_ldgen_strict_fails_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ESPHOME_LDGEN_STRICT turns both degradation paths into hard errors so
CI fails right away when an IDF bump breaks the override."""
monkeypatch.delenv("ESPHOME_LDGEN_FULL_DEPS", raising=False)
monkeypatch.setenv("ESPHOME_LDGEN_STRICT", "1")
content = _render()
assert "--ng" not in content
assert "--format=json2" in content
assert 'message(FATAL_ERROR "ESPHome ldgen app archive exclusion' in content
assert 'message(FATAL_ERROR "ESPHome ldgen override target not found' in content
assert 'message(FATAL_ERROR "ESPHome ldgen override never filtered' in content
assert "@SEVERITY@" not in content
assert "@MISSING@" not in content
def test_get_project_cmakelists_ldgen_full_deps_escape_hatch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ESPHOME_LDGEN_FULL_DEPS restores stock ldgen behavior."""
monkeypatch.setenv("ESPHOME_LDGEN_FULL_DEPS", "true")
content = _render()
assert "__ldgen_get_lib_deps_of_target" not in content
assert "esphome_ldgen_armed" not in content
def test_get_project_cmakelists_uses_supplied_builtin_components() -> None:
+154 -37
View File
@@ -623,6 +623,160 @@ def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path)
assert toolchain.load_cached_builtin_components() is None
@pytest.fixture(autouse=True)
def _clear_ldgen_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Isolate tests from ambient ldgen escape hatch and strict knobs."""
monkeypatch.delenv("ESPHOME_LDGEN_STRICT", raising=False)
monkeypatch.delenv("ESPHOME_LDGEN_FULL_DEPS", raising=False)
def _write_fragments_build_ninja(tmp_path: Path, fragments: list[Path]) -> None:
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True, exist_ok=True)
frag_list = ";".join(str(f) for f in fragments)
(build_dir / "build.ninja").write_text(
f' COMMAND = python ldgen.py --fragments-list "{frag_list}" --input x\n'
)
def test_warn_if_app_archive_mapped_warns(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A fragment naming the app archive, even with trailing text or leading
whitespace, triggers the loud warning."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\n archive: libsrc.a # app\nentries:\n")
_write_fragments_build_ninja(tmp_path, [frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" in caplog.text
def test_warn_if_app_archive_mapped_scans_past_unreadable(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable fragment doesn't stop later fragments being checked."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\narchive: libsrc.a\n")
_write_fragments_build_ninja(tmp_path, [tmp_path / "missing.lf", frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" in caplog.text
def test_warn_if_app_archive_mapped_strict_no_fragments_list(
setup_core: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Under strict, a build.ninja the check can't parse fails the build."""
monkeypatch.setenv("ESPHOME_LDGEN_STRICT", "1")
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("rule CXX\n command = gcc\n")
with pytest.raises(EsphomeError, match="no --fragments-list"):
toolchain._warn_if_app_archive_mapped()
def test_warn_if_app_archive_mapped_strict_raises(
setup_core: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Under ESPHOME_LDGEN_STRICT a mapped app archive fails the build."""
monkeypatch.setenv("ESPHOME_LDGEN_STRICT", "1")
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\narchive: libsrc.a\n")
_write_fragments_build_ninja(tmp_path, [frag])
with pytest.raises(EsphomeError, match="maps the app archive"):
toolchain._warn_if_app_archive_mapped()
def test_warn_if_app_archive_mapped_glob(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A glob archive spec that selects the app archive is also flagged."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\narchive: lib*\n")
_write_fragments_build_ninja(tmp_path, [frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" in caplog.text
def test_warn_if_app_archive_mapped_clean(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Normal fragments, including IDF's stock archive: * catch-all,
produce no warning."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text(
"[mapping:freertos]\narchive: libfreertos.a\n[mapping:default]\narchive: *\n"
)
_write_fragments_build_ninja(tmp_path, [frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" not in caplog.text
def test_warn_if_app_archive_mapped_missing_fragment(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable fragment file is non-fatal."""
_setup_build(setup_core)
_write_fragments_build_ninja(tmp_path, [tmp_path / "missing.lf"])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" not in caplog.text
def test_warn_if_app_archive_mapped_no_build_ninja(setup_core: Path) -> None:
"""No build.ninja yet is a quiet no-op."""
_setup_build(setup_core)
toolchain._warn_if_app_archive_mapped()
def test_warn_if_app_archive_mapped_no_fragments_list(setup_core: Path) -> None:
"""A build.ninja without a fragments-list argument is a quiet no-op."""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("rule CXX\n command = gcc\n")
toolchain._warn_if_app_archive_mapped()
def test_run_compile_runs_fragment_check(setup_core: Path) -> None:
"""The fragment belt runs by default on every compile."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0),
patch.object(toolchain, "print_summary"),
patch.object(toolchain, "_warn_if_app_archive_mapped") as mock_check,
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_check.assert_called_once()
def test_run_compile_full_deps_skips_fragment_check(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ESPHOME_LDGEN_FULL_DEPS disables the fragment belt with the override."""
monkeypatch.setenv("ESPHOME_LDGEN_FULL_DEPS", "1")
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0),
patch.object(toolchain, "print_summary"),
patch.object(toolchain, "_warn_if_app_archive_mapped") as mock_check,
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_check.assert_not_called()
def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None:
"""compile_process_limit is forwarded to run_idf_py as the job limit."""
_setup_build(setup_core)
@@ -638,43 +792,6 @@ def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None:
mock_run.assert_called_once_with("build", "size", jobs=1)
def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None:
"""print_summary receives the size json, partitions.csv, and the built
ELF from get_built_elf_path, which must stay in lockstep with the
project() name in the generated CMakeLists."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0),
patch.object(toolchain, "print_summary") as mock_summary,
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_summary.assert_called_once_with(
CORE.relative_build_path("build", "esp_idf_size.json"),
CORE.relative_build_path("partitions.csv"),
CORE.relative_build_path("build", f"{CORE.name}.elf"),
)
def test_create_elf_copy(setup_core: Path) -> None:
"""The built <name>.elf is copied to the firmware.elf dashboard name."""
_setup_build(setup_core)
src = toolchain.get_built_elf_path()
src.parent.mkdir(parents=True, exist_ok=True)
src.write_bytes(b"elf")
assert toolchain.create_elf_copy() is True
assert toolchain.get_elf_path().read_bytes() == b"elf"
def test_create_elf_copy_missing_source(setup_core: Path) -> None:
"""A missing built ELF is a warning and False, not a crash."""
_setup_build(setup_core)
assert toolchain.create_elf_copy() is False
def test_run_compile_without_compile_process_limit(setup_core: Path) -> None:
"""When no compile_process_limit is set, no job limit is passed to idf.py."""
_setup_build(setup_core)
+56 -230
View File
@@ -4,8 +4,6 @@ from __future__ import annotations
import json
from pathlib import Path
import struct
from unittest.mock import patch
import pytest
@@ -19,106 +17,64 @@ def _write_size_json(tmp_path: Path, data: dict) -> Path:
return out
def _write_partitions(tmp_path: Path) -> Path:
"""Drop a partitions.csv with a 0x1C0000 (1835008 byte) app slot."""
out = tmp_path / "partitions.csv"
out.write_text(
"# name, type, subtype, offset, size, flags\n"
"app0, app, ota_0, 0x10000, 0x1C0000,\n"
)
return out
def _elf_bytes(sections: list[tuple[int, int, int]], shentsize: int = 40) -> bytes:
"""Build a minimal ELF32 LE whose section headers carry the given
(sh_type, sh_flags, sh_size) triples."""
out = bytearray(52)
out[0:4] = b"\x7fELF"
out[4] = out[5] = 1 # 32-bit, little-endian
struct.pack_into("<I", out, 0x20, 52) # e_shoff
struct.pack_into("<HH", out, 0x2E, shentsize, len(sections))
for sh_type, sh_flags, sh_size in sections:
shdr = bytearray(40)
struct.pack_into("<II", shdr, 4, sh_type, sh_flags)
struct.pack_into("<I", shdr, 20, sh_size)
out += shdr
return bytes(out)
def _esp32_size_data() -> dict:
"""Synthetic json2 for the original ESP32 (split IRAM/DRAM), in the
esp-idf-size >= 2.1 shape that carries ``total_size``."""
"""Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM)."""
return {
"version": "1.1",
"total_size": 827455,
"layout": [
{
"name": "DRAM",
"total": 180736,
"image_size": 827455,
"memory_types": {
"DRAM": {
"size": 180736,
"used": 47332,
"free": 133404,
"parts": {
".bss": {"size": 30616},
".data": {"size": 16716},
"sections": {
".dram0.bss": {"abbrev_name": ".bss", "size": 30616},
".dram0.data": {"abbrev_name": ".data", "size": 16716},
},
},
{
"name": "IRAM",
"total": 131072,
"IRAM": {
"size": 131072,
"used": 80351,
"free": 50721,
"parts": {
".text": {"size": 79323},
".vectors": {"size": 1028},
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 79323},
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
},
},
],
},
}
def _s3_size_data() -> dict:
"""Synthetic json2 for ESP32-S3 (unified DIRAM), in the esp-idf-size 1.x
shape without ``total_size``."""
"""Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM)."""
return {
"version": "1.1",
"layout": [
{
"name": "DIRAM",
"total": 341760,
"image_size": 724215,
"memory_types": {
"DIRAM": {
"size": 341760,
"used": 104999,
"free": 236761,
"parts": {
".text": {"size": 58051},
".bss": {"size": 27088},
".data": {"size": 19708},
".noinit": {"size": 152},
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 58051},
".dram0.bss": {"abbrev_name": ".bss", "size": 27088},
".dram0.data": {"abbrev_name": ".data", "size": 19708},
".noinit": {"abbrev_name": ".noinit", "size": 152},
},
},
{
"name": "IRAM",
"total": 16384,
"IRAM": {
"size": 16384,
"used": 16384,
"free": 0,
"parts": {
".text": {"size": 15356},
".vectors": {"size": 1028},
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 15356},
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
},
},
],
},
}
def _print_summary_ram_only(tmp_path: Path, size_json: Path) -> None:
"""Call print_summary with no partitions.csv or ELF on disk."""
print_summary(size_json, tmp_path / "partitions.csv", tmp_path / "firmware.elf")
def test_print_summary_esp32_uses_dram(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Original ESP32: RAM = DRAM.used / DRAM.total."""
"""Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
_print_summary_ram_only(tmp_path, size_json)
print_summary(size_json, partitions_csv=None)
out = capsys.readouterr().out
assert "RAM:" in out
assert "used 47332 bytes from 180736 bytes" in out
@@ -127,193 +83,63 @@ def test_print_summary_esp32_uses_dram(
def test_print_summary_s3_falls_back_to_diram(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""ESP32-S3 with no DRAM entry falls back to DIRAM and reports raw region usage."""
"""ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage."""
size_json = _write_size_json(tmp_path, _s3_size_data())
_print_summary_ram_only(tmp_path, size_json)
print_summary(size_json, partitions_csv=None)
out = capsys.readouterr().out
assert "used 104999 bytes from 341760 bytes" in out
def test_print_summary_skips_when_diram_total_collapses(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A zero-size region drops the RAM line rather than divide by zero."""
size_json = _write_size_json(
tmp_path,
{
"version": "1.1",
"layout": [{"name": "DIRAM", "total": 0, "used": 0}],
"memory_types": {
"DIRAM": {
"size": 0,
"used": 0,
"sections": {},
},
},
},
)
_print_summary_ram_only(tmp_path, size_json)
print_summary(size_json, partitions_csv=None)
out = capsys.readouterr().out
assert "RAM:" not in out
assert "unusable region" in caplog.text
def test_print_summary_handles_missing_json(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Missing size json is non-fatal and prints nothing."""
_print_summary_ram_only(tmp_path, tmp_path / "does_not_exist.json")
print_summary(tmp_path / "does_not_exist.json", partitions_csv=None)
assert capsys.readouterr().out == ""
def test_print_summary_handles_no_layout(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A size json without ``layout`` warns so schema drift is visible."""
size_json = _write_size_json(tmp_path, {"version": "1.1"})
_print_summary_ram_only(tmp_path, size_json)
assert capsys.readouterr().out == ""
assert any(
r.levelname == "WARNING" and "no DRAM/DIRAM region" in r.message
for r in caplog.records
)
def test_print_summary_flash_line_prefers_total_size(
def test_print_summary_handles_no_memory_types(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""With ``total_size`` in the json, that figure wins without reading the
ELF, in the exact shape script/ci_memory_impact_extract.py greps."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
partitions = _write_partitions(tmp_path)
print_summary(size_json, partitions, tmp_path / "firmware.elf")
out = capsys.readouterr().out
assert "Flash: " in out
assert "(used 827455 bytes from 1835008 bytes)" in out
def test_print_summary_flash_line_derives_from_elf(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A 1.x json without ``total_size`` sums the ELF's loadable PROGBITS
sections; NOBITS and non-alloc sections are excluded."""
size_json = _write_size_json(tmp_path, _s3_size_data())
partitions = _write_partitions(tmp_path)
firmware_elf = tmp_path / "firmware.elf"
firmware_elf.write_bytes(
_elf_bytes(
[
(1, 0x6, 700000), # PROGBITS, alloc+exec: counted
(1, 0x2, 24215), # PROGBITS, alloc: counted
(8, 0x2, 50000), # NOBITS (.bss): excluded
(1, 0x0, 12345), # PROGBITS, no alloc (.debug_*): excluded
]
)
)
print_summary(size_json, partitions, firmware_elf)
out = capsys.readouterr().out
assert "(used 724215 bytes from 1835008 bytes)" in out
@pytest.mark.parametrize(
"data",
[
pytest.param([1, 2], id="top_level_list"),
pytest.param({"version": "1.1", "layout": None}, id="layout_null"),
pytest.param({"version": "1.1", "layout": 7}, id="layout_scalar"),
],
)
def test_print_summary_handles_unexpected_shapes(
data: object, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A foreign-schema size json degrades to a warning, never a traceback."""
size_json = _write_size_json(tmp_path, data)
_print_summary_ram_only(tmp_path, size_json)
"""A size json without ``memory_types`` still doesn't crash."""
size_json = _write_size_json(tmp_path, {"image_size": 0})
print_summary(size_json, partitions_csv=None)
assert capsys.readouterr().out == ""
def test_print_summary_skips_flash_on_zero_app_partition(
def test_print_summary_flash_line(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A zero-size app partition skips the Flash line rather than printing
a from-0-bytes figure CI would record."""
"""A partition table with an app row yields the Flash line in the exact
padded shape script/ci_memory_impact_extract.py greps."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
partitions = tmp_path / "partitions.csv"
partitions.write_text(
"# name, type, subtype, offset, size, flags\napp0, app, ota_0, 0x10000, 0x0,\n"
"# name, type, subtype, offset, size, flags\n"
"app0, app, ota_0, 0x10000, 0x1C0000,\n"
)
print_summary(size_json, partitions, tmp_path / "firmware.elf")
print_summary(size_json, partitions)
out = capsys.readouterr().out
assert "Flash:" not in out
def test_print_summary_skips_flash_on_unreadable_partitions(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""An unreadable partitions.csv is non-fatal (chmod tricks don't work
for root in CI containers, so simulate the OSError instead)."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
partitions = _write_partitions(tmp_path)
with patch(
"esphome.espidf.size_summary._find_app_partition_size",
side_effect=PermissionError("denied"),
):
print_summary(size_json, partitions, tmp_path / "firmware.elf")
assert "Flash:" not in capsys.readouterr().out
def test_print_summary_flash_falls_back_on_bad_total_size(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A zero or non-int total_size falls back to the ELF instead of
printing a used-0-bytes line CI would read as a real measurement."""
data = _s3_size_data()
data["total_size"] = 0
size_json = _write_size_json(tmp_path, data)
partitions = _write_partitions(tmp_path)
firmware_elf = tmp_path / "firmware.elf"
firmware_elf.write_bytes(_elf_bytes([(1, 0x2, 4096)]))
print_summary(size_json, partitions, firmware_elf)
out = capsys.readouterr().out
assert "(used 4096 bytes from 1835008 bytes)" in out
_GOOD_ELF = _elf_bytes([(1, 0x2, 1024)])
@pytest.mark.parametrize(
("elf_bytes", "with_partitions"),
[
pytest.param(None, True, id="missing_elf"),
pytest.param(b"junk", True, id="not_an_elf"),
pytest.param(
_elf_bytes([(1, 0x2, 1024)], shentsize=0), True, id="bad_shentsize"
),
pytest.param(_GOOD_ELF[:60], True, id="truncated_table"),
pytest.param(_elf_bytes([]), True, id="no_sections"),
pytest.param(_elf_bytes([(8, 0x2, 50000)]), True, id="no_progbits"),
pytest.param(_GOOD_ELF, False, id="missing_partitions"),
],
)
def test_print_summary_skips_flash_on_bad_input(
elf_bytes: bytes | None,
with_partitions: bool,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""An unusable ELF or missing partitions.csv skips the Flash line, not the RAM line."""
size_json = _write_size_json(tmp_path, _s3_size_data())
firmware_elf = tmp_path / "firmware.elf"
if elf_bytes is not None:
firmware_elf.write_bytes(elf_bytes)
if with_partitions:
_write_partitions(tmp_path)
print_summary(size_json, tmp_path / "partitions.csv", firmware_elf)
out = capsys.readouterr().out
assert "RAM:" in out
assert "Flash:" not in out
# ELF problems warn (anomaly after a successful build); a missing
# partitions.csv stays at debug
warned = any(
r.levelname == "WARNING" and "Skipping Flash summary" in r.message
for r in caplog.records
)
assert warned == with_partitions
assert "Flash: " in out
assert "(used 827455 bytes from 1835008 bytes)" in out