From bcbfc843ae77ec72af2bc2d80b070c07923d8096 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:05:30 -0400 Subject: [PATCH 01/14] [ethernet] Fix SPI3_HOST default breaking compile on variants without SPI3 (#15809) Co-authored-by: J. Nick Koston --- .../components/ethernet/ethernet_component.h | 2 +- .../ethernet/test.esp32-c3-idf.yaml | 19 +++++++++++++++++++ ...720.esp32-idf.yaml => test.esp32-idf.yaml} | 0 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/components/ethernet/test.esp32-c3-idf.yaml rename tests/components/ethernet/{test-lan8720.esp32-idf.yaml => test.esp32-idf.yaml} (100%) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 3a87842315..17c84ee954 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -221,7 +221,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI3_HOST}; + spi_host_device_t interface_{SPI2_HOST}; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif diff --git a/tests/components/ethernet/test.esp32-c3-idf.yaml b/tests/components/ethernet/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b7b95875c6 --- /dev/null +++ b/tests/components/ethernet/test.esp32-c3-idf.yaml @@ -0,0 +1,19 @@ +ethernet: + type: W5500 + clk_pin: 6 + mosi_pin: 7 + miso_pin: 2 + cs_pin: 10 + interrupt_pin: 3 + reset_pin: 4 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-lan8720.esp32-idf.yaml b/tests/components/ethernet/test.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720.esp32-idf.yaml rename to tests/components/ethernet/test.esp32-idf.yaml From 34c35c84d5c0109cb45583a10c0ed9664462013e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 09:31:31 -0500 Subject: [PATCH 02/14] [core] Fix DelayAction compile error with non-const reference args (#15814) --- esphome/core/base_automation.h | 4 +++- tests/components/http_request/http_request.yaml | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 11133d3973..17f937d10d 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -205,7 +205,9 @@ template class DelayAction : public Action, public Compon } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay - auto f = [this, x...]() { this->play_next_(x...); }; + // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) + // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` + auto f = [this, x...]() mutable { this->play_next_(x...); }; App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(x...), std::move(f), diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 13ca5ceba0..ef67671c91 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -45,6 +45,11 @@ esphome: args: - response->status_code - body.c_str() + - delay: 1s + - logger.log: + format: "After delay, body still: %s" + args: + - body.c_str() http_request: useragent: esphome/tagreader From 70ea52716172444dda1d4102d0857a20370c445d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:17:51 -0500 Subject: [PATCH 03/14] Bump ruff from 0.15.10 to 0.15.11 (#15790) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac4f0049f8..e492d35595 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.10 + rev: v0.15.11 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 18d0461e83..bb98375cb6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.10 # also change in .pre-commit-config.yaml when updating +ruff==0.15.11 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 6b67224286d16bb2ee55b6fca475faa445dfdea9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 13:41:39 -0500 Subject: [PATCH 04/14] [core] Chunk setup() into per-component noinline IIFEs Generated setup() is a single monolithic function whose stack frame scales super-linearly with config size. On a 5,943-line apollo build the frame reached 1,264 B at -Os; extrapolation onto larger configs (e.g. the 16k-line LVGL config in #15796) plausibly overflows the 8 KB loop task stack before safe_mode can increment its boot counter. Emit a ComponentMarker sentinel at the start of each component's to_code output, then have cpp_main_section wrap each component's block (and sub-splits of up to 50 statements within each block) in a noinline IIFE lambda. Each lambda's ENTRY frame is released on return, bounding peak stack to setup() frame + max chunk frame. Measured on apollo-r-pro-1-eth (esp32-s3, -Os): setup() frame 1264 B -> 160 B max chunk frame n/a -> 144 B peak setup stack 1264 B -> 304 B (-76%) total flash 792,471 B -> 791,995 B (-476 B) The brace-depth guard in _wrap_in_noinline_iifes ensures we never split between the RawStatement("{") / RawStatement("}") pair emitted by cg.with_local_variable() (currently only wifi), so scoped locals stay intact. --- esphome/__main__.py | 1 + esphome/codegen.py | 1 + esphome/core/__init__.py | 63 +++++++++++++++++++-- esphome/cpp_generator.py | 15 +++++ tests/unit_tests/test_core.py | 100 ++++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 6 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7879cdad0c..5d6cf2972a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -569,6 +569,7 @@ def wrap_to_code(name, comp): @functools.wraps(comp.to_code) async def wrapped(conf): + cg.add(cg.ComponentMarker(name)) cg.add(cg.LineComment(f"{name}:")) if comp.config_schema is not None: conf_str = yaml_util.dump(conf) diff --git a/esphome/codegen.py b/esphome/codegen.py index a5b5abe447..ffaf368bd4 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -10,6 +10,7 @@ # pylint: disable=unused-import from esphome.cpp_generator import ( # noqa: F401 ArrayInitializer, + ComponentMarker, Expression, FlashStringLiteral, LineComment, diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f86..43c1ffaed0 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -531,6 +531,39 @@ class Library: return self +def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: + """Wrap ``lines`` in one or more ``[]() [[gnu::noinline]] { ... }();`` IIFEs. + + Splits into IIFEs of up to ``max_statements`` entries each. Never splits + inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that + ``cg.with_local_variable()`` emits around a scoped local), so an IIFE + may exceed ``max_statements`` when a block straddles the boundary. + """ + out: list[str] = [] + chunk: list[str] = [] + depth = 0 + + def flush() -> None: + if not chunk: + return + out.append("[]() [[gnu::noinline]] {") + out.extend(chunk) + out.append("}();") + chunk.clear() + + for line in lines: + chunk.append(line) + stripped = line.strip() + if stripped == "{": + depth += 1 + elif stripped == "}": + depth -= 1 + if depth == 0 and len(chunk) >= max_statements: + flush() + flush() + return out + + # pylint: disable=too-many-public-methods class EsphomeCore: def __init__(self): @@ -1003,14 +1036,32 @@ class EsphomeCore: @property def cpp_main_section(self): - from esphome.cpp_generator import statement + from esphome.cpp_generator import ComponentMarker, statement - main_code = [] + # Split main_statements at ComponentMarker sentinels into a prefix + # (statements emitted before any component) plus per-component groups. + prefix: list[str] = [] + components: list[list[str]] = [] + current = prefix for exp in self.main_statements: - text = str(statement(exp)) - text = text.rstrip() - main_code.append(text) - return "\n".join(main_code) + "\n\n" + if isinstance(exp, ComponentMarker): + current = [str(exp).rstrip()] + components.append(current) + continue + current.append(str(statement(exp)).rstrip()) + + # No components → flat output (host build, tests). + if not components: + return "\n".join(prefix) + "\n\n" + + # Each component's block is wrapped in a noinline IIFE lambda so its + # stack frame is released on return, bounding peak stack during + # setup(). Large blocks are sub-split to cap single heavy components + # (e.g. sensor platforms with many filter registrations). + pieces = list(prefix) + for block in components: + pieces.extend(_wrap_in_noinline_iifes(block, max_statements=50)) + return "\n".join(pieces) + "\n\n" @property def cpp_global_section(self): diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index cf90b878e1..d7205d1d8f 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -434,6 +434,21 @@ class LineComment(Statement): return "\n".join(parts) +class ComponentMarker(Statement): + """Sentinel marker recorded in main_statements when a component's + to_code begins emitting code. Used by cpp_main_section to split + setup() output into per-component chunks, so each component's + stack frame is released on return.""" + + __slots__ = ("name",) + + def __init__(self, name: str): + self.name = name + + def __str__(self): + return f"// === {self.name} ===" + + class ProgmemAssignmentExpression(AssignmentExpression): __slots__ = () diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 22be59653a..1c31f24066 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -7,6 +7,7 @@ import pytest from strategies import mac_addr_strings from esphome import const, core +from esphome.cpp_generator import ComponentMarker, RawStatement class TestHexInt: @@ -867,3 +868,102 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + +def test_wrap_in_noinline_iifes_empty_input() -> None: + assert not core._wrap_in_noinline_iifes([], max_statements=10) + + +def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: + lines = ["a();", "b();", "c();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=10) == [ + "[]() [[gnu::noinline]] {", + "a();", + "b();", + "c();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_splits_at_max_statements() -> None: + lines = [f"s{i}();" for i in range(5)] + result = core._wrap_in_noinline_iifes(lines, max_statements=2) + # With max=2 and 5 lines: chunks of 2, 2, 1 → 3 IIFEs. + assert sum(1 for line in result if line.startswith("[]()")) == 3 + + +def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: + # max=2 would naively split after "{" but brace guard keeps block whole. + lines = ["a();", "{", "inner();", "}", "b();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=2) == [ + "[]() [[gnu::noinline]] {", + "a();", + "{", + "inner();", + "}", + "}();", + "[]() [[gnu::noinline]] {", + "b();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_nested_braces() -> None: + lines = ["{", "{", "deep();", "}", "}", "after();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=1) == [ + "[]() [[gnu::noinline]] {", + "{", + "{", + "deep();", + "}", + "}", + "}();", + "[]() [[gnu::noinline]] {", + "after();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_unbalanced_braces_fall_through() -> None: + # Pathological input where "}" appears before "{": don't crash; emit + # a single IIFE with all lines rather than splitting mid-flight. + lines = ["a();", "}", "b();"] + result = core._wrap_in_noinline_iifes(lines, max_statements=1) + assert result[0] == "[]() [[gnu::noinline]] {" + assert result[-1] == "}();" + assert [line for line in result if line in lines] == lines + + +def test_cpp_main_section_no_components_emits_flat() -> None: + target = core.EsphomeCore() + target.main_statements = [RawStatement("a();"), RawStatement("b();")] + out = target.cpp_main_section + assert "[[gnu::noinline]]" not in out + assert "a();" in out + assert "b();" in out + + +def test_cpp_main_section_component_marker_wraps_in_iife() -> None: + target = core.EsphomeCore() + target.main_statements = [ + ComponentMarker("logger"), + RawStatement("new_logger();"), + ComponentMarker("wifi"), + RawStatement("new_wifi();"), + ] + out = target.cpp_main_section + assert out.count("[]() [[gnu::noinline]] {") == 2 + assert out.count("}();") == 2 + assert "// === logger ===" in out + assert "// === wifi ===" in out + + +def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: + target = core.EsphomeCore() + target.main_statements = [ + RawStatement("prefix();"), + ComponentMarker("c"), + RawStatement("body();"), + ] + out = target.cpp_main_section + assert out.index("prefix();") < out.index("[]() [[gnu::noinline]] {") From 29dcf9fc5176a84bcf27e98411c5a665028af7c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:13:40 -0500 Subject: [PATCH 05/14] [core] Use __attribute__((noinline)) on IIFE lambdas to honor attribute The C++ standard-attribute spelling [[gnu::noinline]] placed between a lambda's parameter list and body binds to the return type, not the call operator. GCC 14 silently ignores it and emits -Wattributes warnings at every chunk site. Switch to GCC's __attribute__((...)) syntax which binds to operator() as intended. Measured impact on apollo-r-pro-1-eth (esp32-s3, -Os) vs the broken [[gnu::noinline]] version: setup() frame 160 B -> 32 B, peak stack 304 B -> 176 B (another -42%). Flash grows by 888 B because all 86 chunks now stay as separate functions instead of GCC inlining the small ones (which it was free to do when the attribute was ignored). Net vs baseline -Os: peak stack 1264 B -> 176 B (-86%); flash +388 B (<0.05% of a typical esp32 partition). --- esphome/core/__init__.py | 9 +++++++-- tests/unit_tests/test_core.py | 16 ++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 43c1ffaed0..8c44964cac 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -532,12 +532,17 @@ class Library: def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: - """Wrap ``lines`` in one or more ``[]() [[gnu::noinline]] { ... }();`` IIFEs. + """Wrap ``lines`` in one or more ``[]() __attribute__((noinline)) {...}();`` IIFEs. Splits into IIFEs of up to ``max_statements`` entries each. Never splits inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that ``cg.with_local_variable()`` emits around a scoped local), so an IIFE may exceed ``max_statements`` when a block straddles the boundary. + + GCC note: ``__attribute__((noinline))`` between the lambda parameter + list and body binds to ``operator()``. The C++ standard-attribute + spelling ``[[gnu::noinline]]`` in that position binds to the return + type instead and produces ``-Wattributes`` warnings. """ out: list[str] = [] chunk: list[str] = [] @@ -546,7 +551,7 @@ def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: def flush() -> None: if not chunk: return - out.append("[]() [[gnu::noinline]] {") + out.append("[]() __attribute__((noinline)) {") out.extend(chunk) out.append("}();") chunk.clear() diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 1c31f24066..1e4f129896 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -877,7 +877,7 @@ def test_wrap_in_noinline_iifes_empty_input() -> None: def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: lines = ["a();", "b();", "c();"] assert core._wrap_in_noinline_iifes(lines, max_statements=10) == [ - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "a();", "b();", "c();", @@ -896,13 +896,13 @@ def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: # max=2 would naively split after "{" but brace guard keeps block whole. lines = ["a();", "{", "inner();", "}", "b();"] assert core._wrap_in_noinline_iifes(lines, max_statements=2) == [ - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "a();", "{", "inner();", "}", "}();", - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "b();", "}();", ] @@ -911,14 +911,14 @@ def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: def test_wrap_in_noinline_iifes_nested_braces() -> None: lines = ["{", "{", "deep();", "}", "}", "after();"] assert core._wrap_in_noinline_iifes(lines, max_statements=1) == [ - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "{", "{", "deep();", "}", "}", "}();", - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "after();", "}();", ] @@ -929,7 +929,7 @@ def test_wrap_in_noinline_iifes_unbalanced_braces_fall_through() -> None: # a single IIFE with all lines rather than splitting mid-flight. lines = ["a();", "}", "b();"] result = core._wrap_in_noinline_iifes(lines, max_statements=1) - assert result[0] == "[]() [[gnu::noinline]] {" + assert result[0] == "[]() __attribute__((noinline)) {" assert result[-1] == "}();" assert [line for line in result if line in lines] == lines @@ -952,7 +952,7 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section - assert out.count("[]() [[gnu::noinline]] {") == 2 + assert out.count("[]() __attribute__((noinline)) {") == 2 assert out.count("}();") == 2 assert "// === logger ===" in out assert "// === wifi ===" in out @@ -966,4 +966,4 @@ def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: RawStatement("body();"), ] out = target.cpp_main_section - assert out.index("prefix();") < out.index("[]() [[gnu::noinline]] {") + assert out.index("prefix();") < out.index("[]() __attribute__((noinline)) {") From 6a7c9af870ddebaa21fb9a53c5d1f5cf04221de8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:45:46 -0500 Subject: [PATCH 06/14] [core] Drop noinline from IIFE chunks and rename helper Additional measurements showed GCC's -Os inliner re-inlines most IIFE chunks back into setup() by choice, and the structural scoping alone captures nearly all of the peak-stack benefit on esp32 without the flash cost of forcing all chunks to stay as real functions. Apollo (esp32-s3, -Os) with vs without noinline: peak setup stack 176 B (noinline) vs 304 B (scope-only) flash delta +388 B (noinline) vs -504 B (scope-only) chunks kept 86 vs 20 Issue #15796 is an LVGL-setup class of bug that has only surfaced on esp32 after years in the field; the extra guarantee that noinline provides is not worth the flash cost in practice. Also rename the helper from _wrap_in_noinline_iifes to _wrap_in_iifes to match. --- esphome/core/__init__.py | 17 ++++++++------- tests/unit_tests/test_core.py | 40 +++++++++++++++++------------------ 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 8c44964cac..e88add3828 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -531,18 +531,19 @@ class Library: return self -def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: - """Wrap ``lines`` in one or more ``[]() __attribute__((noinline)) {...}();`` IIFEs. +def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: + """Wrap ``lines`` in one or more ``[]() {...}();`` IIFEs. Splits into IIFEs of up to ``max_statements`` entries each. Never splits inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that ``cg.with_local_variable()`` emits around a scoped local), so an IIFE may exceed ``max_statements`` when a block straddles the boundary. - GCC note: ``__attribute__((noinline))`` between the lambda parameter - list and body binds to ``operator()``. The C++ standard-attribute - spelling ``[[gnu::noinline]]`` in that position binds to the return - type instead and produces ``-Wattributes`` warnings. + The IIFEs intentionally have no ``noinline`` attribute: GCC's ``-Os`` + inliner makes good decisions about which chunks to keep as functions + and which to re-inline, and forcing all chunks to stay as functions + costs flash without measurably improving peak stack on configs where + the scope structure is itself sufficient to bound live-range lifetimes. """ out: list[str] = [] chunk: list[str] = [] @@ -551,7 +552,7 @@ def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: def flush() -> None: if not chunk: return - out.append("[]() __attribute__((noinline)) {") + out.append("[]() {") out.extend(chunk) out.append("}();") chunk.clear() @@ -1065,7 +1066,7 @@ class EsphomeCore: # (e.g. sensor platforms with many filter registrations). pieces = list(prefix) for block in components: - pieces.extend(_wrap_in_noinline_iifes(block, max_statements=50)) + pieces.extend(_wrap_in_iifes(block, max_statements=50)) return "\n".join(pieces) + "\n\n" @property diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 1e4f129896..02a804e6a1 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -870,14 +870,14 @@ class TestEsphomeCore: assert "Wire" in target.platformio_libraries -def test_wrap_in_noinline_iifes_empty_input() -> None: - assert not core._wrap_in_noinline_iifes([], max_statements=10) +def test_wrap_in_iifes_empty_input() -> None: + assert not core._wrap_in_iifes([], max_statements=10) -def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: +def test_wrap_in_iifes_fewer_lines_than_limit() -> None: lines = ["a();", "b();", "c();"] - assert core._wrap_in_noinline_iifes(lines, max_statements=10) == [ - "[]() __attribute__((noinline)) {", + assert core._wrap_in_iifes(lines, max_statements=10) == [ + "[]() {", "a();", "b();", "c();", @@ -885,51 +885,51 @@ def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: ] -def test_wrap_in_noinline_iifes_splits_at_max_statements() -> None: +def test_wrap_in_iifes_splits_at_max_statements() -> None: lines = [f"s{i}();" for i in range(5)] - result = core._wrap_in_noinline_iifes(lines, max_statements=2) + result = core._wrap_in_iifes(lines, max_statements=2) # With max=2 and 5 lines: chunks of 2, 2, 1 → 3 IIFEs. assert sum(1 for line in result if line.startswith("[]()")) == 3 -def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: +def test_wrap_in_iifes_never_splits_inside_braces() -> None: # max=2 would naively split after "{" but brace guard keeps block whole. lines = ["a();", "{", "inner();", "}", "b();"] - assert core._wrap_in_noinline_iifes(lines, max_statements=2) == [ - "[]() __attribute__((noinline)) {", + assert core._wrap_in_iifes(lines, max_statements=2) == [ + "[]() {", "a();", "{", "inner();", "}", "}();", - "[]() __attribute__((noinline)) {", + "[]() {", "b();", "}();", ] -def test_wrap_in_noinline_iifes_nested_braces() -> None: +def test_wrap_in_iifes_nested_braces() -> None: lines = ["{", "{", "deep();", "}", "}", "after();"] - assert core._wrap_in_noinline_iifes(lines, max_statements=1) == [ - "[]() __attribute__((noinline)) {", + assert core._wrap_in_iifes(lines, max_statements=1) == [ + "[]() {", "{", "{", "deep();", "}", "}", "}();", - "[]() __attribute__((noinline)) {", + "[]() {", "after();", "}();", ] -def test_wrap_in_noinline_iifes_unbalanced_braces_fall_through() -> None: +def test_wrap_in_iifes_unbalanced_braces_fall_through() -> None: # Pathological input where "}" appears before "{": don't crash; emit # a single IIFE with all lines rather than splitting mid-flight. lines = ["a();", "}", "b();"] - result = core._wrap_in_noinline_iifes(lines, max_statements=1) - assert result[0] == "[]() __attribute__((noinline)) {" + result = core._wrap_in_iifes(lines, max_statements=1) + assert result[0] == "[]() {" assert result[-1] == "}();" assert [line for line in result if line in lines] == lines @@ -952,7 +952,7 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section - assert out.count("[]() __attribute__((noinline)) {") == 2 + assert out.count("[]() {") == 2 assert out.count("}();") == 2 assert "// === logger ===" in out assert "// === wifi ===" in out @@ -966,4 +966,4 @@ def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: RawStatement("body();"), ] out = target.cpp_main_section - assert out.index("prefix();") < out.index("[]() __attribute__((noinline)) {") + assert out.index("prefix();") < out.index("[]() {") From 936694af2c801c96fbd126cb21f8f2d2331536db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:49:14 -0500 Subject: [PATCH 07/14] [core] Don't emit IIFE for comment-only chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some components (sha256, async_tcp, network, empty text_sensor:, etc.) emit only a ComponentMarker plus config-dump comments and no actual C++ statements. Wrapping those in a `[]() { ... }();` IIFE is pure clutter in the generated main.cpp — the IIFE has no body. When _wrap_in_iifes sees a chunk whose lines are all // comments, emit them verbatim instead of wrapping. Peak stack and flash are unchanged on apollo and neargaragedoor since GCC was already eliding the empty IIFEs; this just makes the generated code read cleanly to humans. --- esphome/core/__init__.py | 12 +++++++++--- tests/unit_tests/test_core.py | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e88add3828..b66899e133 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -552,9 +552,15 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: def flush() -> None: if not chunk: return - out.append("[]() {") - out.extend(chunk) - out.append("}();") + # If the chunk is comments-only (e.g. a component that emits a + # header marker and config dump but no C++ statements), emit them + # verbatim without wrapping — an empty IIFE is pure clutter. + if all(line.lstrip().startswith("//") for line in chunk): + out.extend(chunk) + else: + out.append("[]() {") + out.extend(chunk) + out.append("}();") chunk.clear() for line in lines: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 02a804e6a1..cd65e670ed 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -934,6 +934,13 @@ def test_wrap_in_iifes_unbalanced_braces_fall_through() -> None: assert [line for line in result if line in lines] == lines +def test_wrap_in_iifes_skips_comment_only_chunks() -> None: + # Components that emit only a ComponentMarker + config dump (no C++ + # statements) should not be wrapped in an empty IIFE. + lines = ["// === sha256 ===", "// sha256:", "// {}"] + assert core._wrap_in_iifes(lines, max_statements=50) == lines + + def test_cpp_main_section_no_components_emits_flat() -> None: target = core.EsphomeCore() target.main_statements = [RawStatement("a();"), RawStatement("b();")] From 864d31aa65c73dffb3500b83e521fc9bf2b234b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:53:59 -0500 Subject: [PATCH 08/14] [core] Put ComponentMarker outside the IIFE as a visual bracket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker comment was being emitted as the first line *inside* each IIFE: []() { // === logger === // logger: // ... ... }(); That works but buries the component label inside the lambda body, so scanning generated main.cpp to find "where does component X's setup live" is harder than it needs to be. Emit the marker before and after the IIFE instead: // === logger === []() { // logger: // ... ... }(); // === logger === Comment-only components (e.g. sha256, async_tcp, empty platforms like binary_sensor:) don't grow a useless trailing duplicate marker — when there's no IIFE to bracket, the marker is emitted once. --- esphome/core/__init__.py | 27 ++++++++++++++++++--------- tests/unit_tests/test_core.py | 19 +++++++++++++++++-- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index b66899e133..acf2e197ac 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1052,13 +1052,16 @@ class EsphomeCore: # Split main_statements at ComponentMarker sentinels into a prefix # (statements emitted before any component) plus per-component groups. + # Each group's first entry is its marker comment, kept separate so + # it can bracket the IIFE rather than be buried inside it. prefix: list[str] = [] - components: list[list[str]] = [] + components: list[tuple[str, list[str]]] = [] current = prefix for exp in self.main_statements: if isinstance(exp, ComponentMarker): - current = [str(exp).rstrip()] - components.append(current) + body: list[str] = [] + components.append((str(exp).rstrip(), body)) + current = body continue current.append(str(statement(exp)).rstrip()) @@ -1066,13 +1069,19 @@ class EsphomeCore: if not components: return "\n".join(prefix) + "\n\n" - # Each component's block is wrapped in a noinline IIFE lambda so its - # stack frame is released on return, bounding peak stack during - # setup(). Large blocks are sub-split to cap single heavy components - # (e.g. sensor platforms with many filter registrations). + # Each component's block is wrapped in IIFE lambdas so its stack + # frame is released on return, bounding peak stack during setup(). + # Large blocks are sub-split to cap single heavy components (e.g. + # sensor platforms with many filter registrations). The marker + # comment brackets the IIFE on both sides so the generated + # main.cpp is easy to scan by component. pieces = list(prefix) - for block in components: - pieces.extend(_wrap_in_iifes(block, max_statements=50)) + for marker, body in components: + wrapped = _wrap_in_iifes(body, max_statements=50) + pieces.append(marker) + pieces.extend(wrapped) + if any("[]()" in line for line in wrapped): + pieces.append(marker) return "\n".join(pieces) + "\n\n" @property diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cd65e670ed..dc16d4cfe9 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -961,8 +961,23 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: out = target.cpp_main_section assert out.count("[]() {") == 2 assert out.count("}();") == 2 - assert "// === logger ===" in out - assert "// === wifi ===" in out + # Each component's marker brackets its IIFE (once before, once after). + assert out.count("// === logger ===") == 2 + assert out.count("// === wifi ===") == 2 + + +def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: + # A component that emits no C++ statements (only a ComponentMarker) + # should not grow a useless trailing duplicate marker. + target = core.EsphomeCore() + target.main_statements = [ + ComponentMarker("sha256"), + ComponentMarker("wifi"), + RawStatement("new_wifi();"), + ] + out = target.cpp_main_section + assert out.count("// === sha256 ===") == 1 + assert out.count("// === wifi ===") == 2 # wifi has an IIFE def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: From 178f23a7aaf9f4b42b33e322eebe1d3b500b5230 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:55:40 -0500 Subject: [PATCH 09/14] [core] Use begin/end marker pairs around each component's IIFE Rename the bracket markers from "// === X ===" (same on both sides) to "// === begin X ===" and "// === end X ===" so the generated main.cpp reads unambiguously when scanning by component. Comment-only components still get a single "begin X" marker since they have no IIFE to close. --- esphome/core/__init__.py | 22 ++++++++++++---------- esphome/cpp_generator.py | 7 +++---- tests/unit_tests/test_core.py | 18 +++++++++++------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index acf2e197ac..798a797222 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1052,15 +1052,15 @@ class EsphomeCore: # Split main_statements at ComponentMarker sentinels into a prefix # (statements emitted before any component) plus per-component groups. - # Each group's first entry is its marker comment, kept separate so - # it can bracket the IIFE rather than be buried inside it. + # Each group carries its component name so cpp_main_section can emit + # begin/end marker comments bracketing the IIFE. prefix: list[str] = [] components: list[tuple[str, list[str]]] = [] current = prefix for exp in self.main_statements: if isinstance(exp, ComponentMarker): body: list[str] = [] - components.append((str(exp).rstrip(), body)) + components.append((exp.name, body)) current = body continue current.append(str(statement(exp)).rstrip()) @@ -1072,16 +1072,18 @@ class EsphomeCore: # Each component's block is wrapped in IIFE lambdas so its stack # frame is released on return, bounding peak stack during setup(). # Large blocks are sub-split to cap single heavy components (e.g. - # sensor platforms with many filter registrations). The marker - # comment brackets the IIFE on both sides so the generated - # main.cpp is easy to scan by component. + # sensor platforms with many filter registrations). "begin X" and + # "end X" marker comments bracket the IIFE so the generated + # main.cpp is easy to scan by component; a comment-only component + # gets a single "begin X" marker (no IIFE, no end marker). pieces = list(prefix) - for marker, body in components: + for name, body in components: wrapped = _wrap_in_iifes(body, max_statements=50) - pieces.append(marker) + has_iife = any("[]()" in line for line in wrapped) + pieces.append(f"// === begin {name} ===") pieces.extend(wrapped) - if any("[]()" in line for line in wrapped): - pieces.append(marker) + if has_iife: + pieces.append(f"// === end {name} ===") return "\n".join(pieces) + "\n\n" @property diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index d7205d1d8f..608f55aa0c 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -436,9 +436,8 @@ class LineComment(Statement): class ComponentMarker(Statement): """Sentinel marker recorded in main_statements when a component's - to_code begins emitting code. Used by cpp_main_section to split - setup() output into per-component chunks, so each component's - stack frame is released on return.""" + to_code begins emitting code. ``cpp_main_section`` consumes these + to bracket each component's IIFE with begin/end comment markers.""" __slots__ = ("name",) @@ -446,7 +445,7 @@ class ComponentMarker(Statement): self.name = name def __str__(self): - return f"// === {self.name} ===" + return f"// === begin {self.name} ===" class ProgmemAssignmentExpression(AssignmentExpression): diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index dc16d4cfe9..f23955672d 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -937,7 +937,7 @@ def test_wrap_in_iifes_unbalanced_braces_fall_through() -> None: def test_wrap_in_iifes_skips_comment_only_chunks() -> None: # Components that emit only a ComponentMarker + config dump (no C++ # statements) should not be wrapped in an empty IIFE. - lines = ["// === sha256 ===", "// sha256:", "// {}"] + lines = ["// === begin sha256 ===", "// sha256:", "// {}"] assert core._wrap_in_iifes(lines, max_statements=50) == lines @@ -961,14 +961,16 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: out = target.cpp_main_section assert out.count("[]() {") == 2 assert out.count("}();") == 2 - # Each component's marker brackets its IIFE (once before, once after). - assert out.count("// === logger ===") == 2 - assert out.count("// === wifi ===") == 2 + # Each component's IIFE is bracketed by a begin/end marker pair. + assert "// === begin logger ===" in out + assert "// === end logger ===" in out + assert "// === begin wifi ===" in out + assert "// === end wifi ===" in out def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: # A component that emits no C++ statements (only a ComponentMarker) - # should not grow a useless trailing duplicate marker. + # gets only a begin marker — no IIFE, so no end marker. target = core.EsphomeCore() target.main_statements = [ ComponentMarker("sha256"), @@ -976,8 +978,10 @@ def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section - assert out.count("// === sha256 ===") == 1 - assert out.count("// === wifi ===") == 2 # wifi has an IIFE + assert "// === begin sha256 ===" in out + assert "// === end sha256 ===" not in out + assert "// === begin wifi ===" in out + assert "// === end wifi ===" in out def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: From f82401a5044b9fc3b657fb5ddce3bc46777520c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:04:07 -0500 Subject: [PATCH 10/14] [core] Address Copilot review: robust brace depth, accurate docstrings - Count { and } characters per line instead of matching whole-line tokens. Current codegen only emits scope braces as standalone lines (from cg.with_local_variable()), but the defensive change is robust against future codegen emitting inline control flow like `if (cond) {` or `} else {` on one line. - Add a regression test covering those inline-brace patterns. - Fix stale docstrings on ComponentMarker and cpp_main_section that still claimed "stack frame released on return" and described the IIFEs as "noinline". The IIFEs have no noinline attribute and rely on scope-based lifetime shortening rather than guaranteed frames. --- esphome/core/__init__.py | 35 +++++++++++++++++++++++------------ esphome/cpp_generator.py | 7 ++++++- tests/unit_tests/test_core.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 798a797222..b69cb74a74 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -565,11 +565,18 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: for line in lines: chunk.append(line) - stripped = line.strip() - if stripped == "{": - depth += 1 - elif stripped == "}": - depth -= 1 + # Track brace depth by counting ``{`` and ``}`` characters per line + # rather than matching whole-line tokens. Today the only codegen + # that emits scope braces as separate statements is + # ``cg.with_local_variable()`` (standalone ``{`` / ``}`` lines), + # but counting is robust against future codegen that emits inline + # control-flow like ``if (cond) {`` or ``} else {`` on a single + # line. Multi-line statements (inline lambdas) carry balanced + # braces within one list entry so they contribute no net depth. + # Braces inside string literals would throw the count off, but + # esphome's generated main.cpp does not currently emit strings + # that contain unbalanced braces in main_statements. + depth += line.count("{") - line.count("}") if depth == 0 and len(chunk) >= max_statements: flush() flush() @@ -1069,13 +1076,17 @@ class EsphomeCore: if not components: return "\n".join(prefix) + "\n\n" - # Each component's block is wrapped in IIFE lambdas so its stack - # frame is released on return, bounding peak stack during setup(). - # Large blocks are sub-split to cap single heavy components (e.g. - # sensor platforms with many filter registrations). "begin X" and - # "end X" marker comments bracket the IIFE so the generated - # main.cpp is easy to scan by component; a comment-only component - # gets a single "begin X" marker (no IIFE, no end marker). + # Each component's block is wrapped in an IIFE lambda that + # introduces a nested scope, shortening the lifetimes of + # temporaries so GCC can bound peak setup-time stack usage. + # The IIFE has no noinline attribute, so the compiler is free + # to inline the block when that produces smaller code without + # regressing peak stack. Large blocks are sub-split to cap + # single heavy components (e.g. sensor platforms with many + # filter registrations). "begin X" and "end X" marker comments + # bracket the IIFE so the generated main.cpp is easy to scan by + # component; a comment-only component gets a single "begin X" + # marker (no IIFE, no end marker). pieces = list(prefix) for name, body in components: wrapped = _wrap_in_iifes(body, max_statements=50) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 608f55aa0c..6b33fe3e22 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -437,7 +437,12 @@ class LineComment(Statement): class ComponentMarker(Statement): """Sentinel marker recorded in main_statements when a component's to_code begins emitting code. ``cpp_main_section`` consumes these - to bracket each component's IIFE with begin/end comment markers.""" + to bracket each component's generated block with begin/end comment + markers and to wrap it in an IIFE scope. The IIFE introduces a + nested scope so GCC can shorten temporary lifetimes and help reduce + peak setup-time stack usage; the lambda has no ``noinline`` + attribute, so the compiler may still inline the block when that + produces smaller code without regressing peak stack.""" __slots__ = ("name",) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index f23955672d..e01593bba8 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -934,6 +934,40 @@ def test_wrap_in_iifes_unbalanced_braces_fall_through() -> None: assert [line for line in result if line in lines] == lines +def test_wrap_in_iifes_never_splits_inline_brace_lines() -> None: + # Defensive: if codegen ever emits control flow with braces on the + # same line (if/else/for), the depth tracker should keep the whole + # scoped block together even with aggressive max_statements. + lines = [ + "before();", + "if (cond) {", + "then_branch();", + "} else {", + "for (;;) {", + "loop_body();", + "}", + "}", + "after();", + ] + assert core._wrap_in_iifes(lines, max_statements=1) == [ + "[]() {", + "before();", + "}();", + "[]() {", + "if (cond) {", + "then_branch();", + "} else {", + "for (;;) {", + "loop_body();", + "}", + "}", + "}();", + "[]() {", + "after();", + "}();", + ] + + def test_wrap_in_iifes_skips_comment_only_chunks() -> None: # Components that emit only a ComponentMarker + config dump (no C++ # statements) should not be wrapped in an empty IIFE. From 00f08ba6ed6413b9b4e214dfad56052128721e1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:19:48 -0500 Subject: [PATCH 11/14] [core] Drop per-component begin/end labels from generated main.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The labels were there to help humans scanning the generated main.cpp find component boundaries, but they were: - Unreliable: CORE.flush_tasks can interleave coroutines on each await, so a component's later statements can land in another component's begin/end block. - Load-bearing for a pile of complexity: a tuple return from _wrap_in_iifes, a has_iife flag, a comment-only detector to suppress trailing end-markers for comment-only components, and a brittle `"[]()" in line` check that could false-positive on YAML dumps containing lambda syntax. - Not actually needed — generated main.cpp is a build artifact rarely read by anyone, and cg.LineComment("name:") already puts the component name at the start of its block. ComponentMarker stays as a pure chunking sentinel — it tells cpp_main_section where component boundaries are (for grouping) but produces no C++ output. _wrap_in_iifes returns a plain list again. Added a regression test for the now-defused case of a comment containing "[]()" that was previously flagged by review. --- esphome/core/__init__.py | 70 ++++++++++++++++------------------- esphome/cpp_generator.py | 25 +++++++++---- tests/unit_tests/test_core.py | 42 ++++++++++++--------- 3 files changed, 73 insertions(+), 64 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index b69cb74a74..2abf15e1c7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -538,6 +538,8 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that ``cg.with_local_variable()`` emits around a scoped local), so an IIFE may exceed ``max_statements`` when a block straddles the boundary. + A comment-only chunk is emitted verbatim with no IIFE, since wrapping + pure comments in a no-op lambda is clutter. The IIFEs intentionally have no ``noinline`` attribute: GCC's ``-Os`` inliner makes good decisions about which chunks to keep as functions @@ -552,9 +554,6 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: def flush() -> None: if not chunk: return - # If the chunk is comments-only (e.g. a component that emits a - # header marker and config dump but no C++ statements), emit them - # verbatim without wrapping — an empty IIFE is pure clutter. if all(line.lstrip().startswith("//") for line in chunk): out.extend(chunk) else: @@ -566,16 +565,22 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: for line in lines: chunk.append(line) # Track brace depth by counting ``{`` and ``}`` characters per line - # rather than matching whole-line tokens. Today the only codegen - # that emits scope braces as separate statements is - # ``cg.with_local_variable()`` (standalone ``{`` / ``}`` lines), - # but counting is robust against future codegen that emits inline - # control-flow like ``if (cond) {`` or ``} else {`` on a single - # line. Multi-line statements (inline lambdas) carry balanced - # braces within one list entry so they contribute no net depth. - # Braces inside string literals would throw the count off, but - # esphome's generated main.cpp does not currently emit strings - # that contain unbalanced braces in main_statements. + # rather than matching whole-line tokens. The current codegen only + # emits scope braces via ``cg.with_local_variable()`` (standalone + # ``{`` / ``}`` lines), but counting is robust against future + # codegen emitting inline control flow like ``if (cond) {`` or + # ``} else {``. Multi-line statements (e.g. inline lambdas) carry + # balanced braces within one list entry and contribute no net + # depth. Braces inside string literals would throw the count off; + # esphome's generated main.cpp does not currently emit such + # strings in main_statements. + # + # If depth ever goes negative (unmatched ``}`` before ``{``), we + # never return to depth 0 for the remainder of the input, so no + # further flushes fire and the rest of the chunk falls through + # into a single IIFE at the final ``flush()``. This is the + # intended safe-harbor behavior — negative depth signals a + # violated assumption about the input, not a normal branch. depth += line.count("{") - line.count("}") if depth == 0 and len(chunk) >= max_statements: flush() @@ -1058,43 +1063,30 @@ class EsphomeCore: from esphome.cpp_generator import ComponentMarker, statement # Split main_statements at ComponentMarker sentinels into a prefix - # (statements emitted before any component) plus per-component groups. - # Each group carries its component name so cpp_main_section can emit - # begin/end marker comments bracketing the IIFE. + # (statements emitted before any component) plus per-component + # groups. Each group is wrapped in an IIFE lambda so GCC can + # shorten temporary lifetimes and bound peak setup-time stack; + # large groups are sub-split to cap single heavy components + # (e.g. sensor platforms with many filter registrations). The + # IIFEs have no noinline attribute, so the compiler is free to + # inline the block when that produces smaller code without + # regressing peak stack. prefix: list[str] = [] - components: list[tuple[str, list[str]]] = [] + components: list[list[str]] = [] current = prefix for exp in self.main_statements: if isinstance(exp, ComponentMarker): - body: list[str] = [] - components.append((exp.name, body)) - current = body + current = [] + components.append(current) continue current.append(str(statement(exp)).rstrip()) - # No components → flat output (host build, tests). if not components: return "\n".join(prefix) + "\n\n" - # Each component's block is wrapped in an IIFE lambda that - # introduces a nested scope, shortening the lifetimes of - # temporaries so GCC can bound peak setup-time stack usage. - # The IIFE has no noinline attribute, so the compiler is free - # to inline the block when that produces smaller code without - # regressing peak stack. Large blocks are sub-split to cap - # single heavy components (e.g. sensor platforms with many - # filter registrations). "begin X" and "end X" marker comments - # bracket the IIFE so the generated main.cpp is easy to scan by - # component; a comment-only component gets a single "begin X" - # marker (no IIFE, no end marker). pieces = list(prefix) - for name, body in components: - wrapped = _wrap_in_iifes(body, max_statements=50) - has_iife = any("[]()" in line for line in wrapped) - pieces.append(f"// === begin {name} ===") - pieces.extend(wrapped) - if has_iife: - pieces.append(f"// === end {name} ===") + for body in components: + pieces.extend(_wrap_in_iifes(body, max_statements=50)) return "\n".join(pieces) + "\n\n" @property diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 6b33fe3e22..3b7b78dc90 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -436,13 +436,22 @@ class LineComment(Statement): class ComponentMarker(Statement): """Sentinel marker recorded in main_statements when a component's - to_code begins emitting code. ``cpp_main_section`` consumes these - to bracket each component's generated block with begin/end comment - markers and to wrap it in an IIFE scope. The IIFE introduces a - nested scope so GCC can shorten temporary lifetimes and help reduce - peak setup-time stack usage; the lambda has no ``noinline`` - attribute, so the compiler may still inline the block when that - produces smaller code without regressing peak stack.""" + ``to_code`` begins emitting code. ``cpp_main_section`` consumes + these as chunking boundaries: the statements between two markers + form a group that gets wrapped in an IIFE so GCC can shorten + temporary lifetimes and bound peak setup-time stack usage. + + The marker produces no C++ output of its own; its ``__str__`` is + only used for debugging (e.g. ``repr`` of ``main_statements``). + The component name is retained so tooling can inspect grouping if + needed, but the generated ``main.cpp`` carries no per-component + labels — partitioning is best-effort anyway because + ``CORE.flush_tasks`` can interleave coroutines on each ``await`` + (e.g. ``cg.get_variable``) and re-schedule by priority, which + means a component's later statements can land between a different + component's earlier statements. This is semantically safe because + every statement placement-news into static storage or mutates a + global already declared at file scope.""" __slots__ = ("name",) @@ -450,7 +459,7 @@ class ComponentMarker(Statement): self.name = name def __str__(self): - return f"// === begin {self.name} ===" + return f"// component-marker: {self.name}" class ProgmemAssignmentExpression(AssignmentExpression): diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index e01593bba8..e4a52a7808 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -871,7 +871,7 @@ class TestEsphomeCore: def test_wrap_in_iifes_empty_input() -> None: - assert not core._wrap_in_iifes([], max_statements=10) + assert core._wrap_in_iifes([], max_statements=10) == [] def test_wrap_in_iifes_fewer_lines_than_limit() -> None: @@ -969,9 +969,20 @@ def test_wrap_in_iifes_never_splits_inline_brace_lines() -> None: def test_wrap_in_iifes_skips_comment_only_chunks() -> None: - # Components that emit only a ComponentMarker + config dump (no C++ - # statements) should not be wrapped in an empty IIFE. - lines = ["// === begin sha256 ===", "// sha256:", "// {}"] + # A chunk with no C++ statements (only comments, e.g. a component's + # config dump) should be emitted verbatim without a no-op IIFE. + lines = ["// sha256:", "// {}"] + assert core._wrap_in_iifes(lines, max_statements=50) == lines + + +def test_wrap_in_iifes_ignores_iife_pattern_in_comment() -> None: + # A comment whose text mentions "[]()" (e.g. a YAML dump of a + # lambda) must not fool the comment-only detector into wrapping. + lines = [ + "// on_value:", + "// - !lambda |-", + "// return []() { return 5; };", + ] assert core._wrap_in_iifes(lines, max_statements=50) == lines @@ -979,7 +990,7 @@ def test_cpp_main_section_no_components_emits_flat() -> None: target = core.EsphomeCore() target.main_statements = [RawStatement("a();"), RawStatement("b();")] out = target.cpp_main_section - assert "[[gnu::noinline]]" not in out + assert "[]() {" not in out assert "a();" in out assert "b();" in out @@ -993,18 +1004,17 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section + # One IIFE per component that emits C++ statements. assert out.count("[]() {") == 2 assert out.count("}();") == 2 - # Each component's IIFE is bracketed by a begin/end marker pair. - assert "// === begin logger ===" in out - assert "// === end logger ===" in out - assert "// === begin wifi ===" in out - assert "// === end wifi ===" in out + # ComponentMarker produces no output of its own. + assert "component-marker" not in out -def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: - # A component that emits no C++ statements (only a ComponentMarker) - # gets only a begin marker — no IIFE, so no end marker. +def test_cpp_main_section_comment_only_component_omits_iife() -> None: + # A component that emits only a ComponentMarker (no statements) adds + # nothing to the generated output. A neighboring component with + # actual code still gets its own IIFE. target = core.EsphomeCore() target.main_statements = [ ComponentMarker("sha256"), @@ -1012,10 +1022,8 @@ def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section - assert "// === begin sha256 ===" in out - assert "// === end sha256 ===" not in out - assert "// === begin wifi ===" in out - assert "// === end wifi ===" in out + assert out.count("[]() {") == 1 + assert "new_wifi();" in out def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: From 91b238aa97736d38f945dc66c23087ee38e772cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:34:15 -0500 Subject: [PATCH 12/14] [core] Fix grammar in ComponentMarker docstring --- esphome/cpp_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 3b7b78dc90..8abebd8b6e 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -450,8 +450,8 @@ class ComponentMarker(Statement): (e.g. ``cg.get_variable``) and re-schedule by priority, which means a component's later statements can land between a different component's earlier statements. This is semantically safe because - every statement placement-news into static storage or mutates a - global already declared at file scope.""" + every statement either uses placement new into static storage or + mutates a global already declared at file scope.""" __slots__ = ("name",) From 9fa6d224c27ac62dc5a96d17691df8cf76aaa991 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:35:53 -0500 Subject: [PATCH 13/14] [core] Tighten docstrings and inline comments --- esphome/core/__init__.py | 54 +++++++++++----------------------------- esphome/cpp_generator.py | 23 ++++++----------- 2 files changed, 21 insertions(+), 56 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 2abf15e1c7..8bc4e6e153 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -532,21 +532,13 @@ class Library: def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: - """Wrap ``lines`` in one or more ``[]() {...}();`` IIFEs. + """Wrap ``lines`` in ``[]() {...}();`` IIFEs of up to ``max_statements`` + each. Never splits inside a brace-balanced block (e.g. the ``{`` / ``}`` + pair from ``cg.with_local_variable()``), so an IIFE may exceed the cap + when a block straddles it. Comment-only chunks pass through verbatim. - Splits into IIFEs of up to ``max_statements`` entries each. Never splits - inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that - ``cg.with_local_variable()`` emits around a scoped local), so an IIFE - may exceed ``max_statements`` when a block straddles the boundary. - A comment-only chunk is emitted verbatim with no IIFE, since wrapping - pure comments in a no-op lambda is clutter. - - The IIFEs intentionally have no ``noinline`` attribute: GCC's ``-Os`` - inliner makes good decisions about which chunks to keep as functions - and which to re-inline, and forcing all chunks to stay as functions - costs flash without measurably improving peak stack on configs where - the scope structure is itself sufficient to bound live-range lifetimes. - """ + No ``noinline`` attribute — GCC's inliner re-folds small chunks freely, + keeping flash small without regressing peak stack.""" out: list[str] = [] chunk: list[str] = [] depth = 0 @@ -564,23 +556,10 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: for line in lines: chunk.append(line) - # Track brace depth by counting ``{`` and ``}`` characters per line - # rather than matching whole-line tokens. The current codegen only - # emits scope braces via ``cg.with_local_variable()`` (standalone - # ``{`` / ``}`` lines), but counting is robust against future - # codegen emitting inline control flow like ``if (cond) {`` or - # ``} else {``. Multi-line statements (e.g. inline lambdas) carry - # balanced braces within one list entry and contribute no net - # depth. Braces inside string literals would throw the count off; - # esphome's generated main.cpp does not currently emit such - # strings in main_statements. - # - # If depth ever goes negative (unmatched ``}`` before ``{``), we - # never return to depth 0 for the remainder of the input, so no - # further flushes fire and the rest of the chunk falls through - # into a single IIFE at the final ``flush()``. This is the - # intended safe-harbor behavior — negative depth signals a - # violated assumption about the input, not a normal branch. + # Count { and } per line so inline control flow (e.g. `if (cond) {`) + # and balanced inline lambdas are tracked correctly. If depth ever + # goes negative (unbalanced input) we never return to 0 and the + # rest falls through into a single final IIFE — safe fallback. depth += line.count("{") - line.count("}") if depth == 0 and len(chunk) >= max_statements: flush() @@ -1062,15 +1041,10 @@ class EsphomeCore: def cpp_main_section(self): from esphome.cpp_generator import ComponentMarker, statement - # Split main_statements at ComponentMarker sentinels into a prefix - # (statements emitted before any component) plus per-component - # groups. Each group is wrapped in an IIFE lambda so GCC can - # shorten temporary lifetimes and bound peak setup-time stack; - # large groups are sub-split to cap single heavy components - # (e.g. sensor platforms with many filter registrations). The - # IIFEs have no noinline attribute, so the compiler is free to - # inline the block when that produces smaller code without - # regressing peak stack. + # Split main_statements at ComponentMarker sentinels and wrap each + # component's group in an IIFE, sub-splitting at 50 statements so + # a single heavy component (e.g. a sensor platform with many + # filter registrations) can't blow the peak chunk frame. prefix: list[str] = [] components: list[list[str]] = [] current = prefix diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 8abebd8b6e..853025b4aa 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -435,23 +435,14 @@ class LineComment(Statement): class ComponentMarker(Statement): - """Sentinel marker recorded in main_statements when a component's - ``to_code`` begins emitting code. ``cpp_main_section`` consumes - these as chunking boundaries: the statements between two markers - form a group that gets wrapped in an IIFE so GCC can shorten - temporary lifetimes and bound peak setup-time stack usage. + """Chunking-boundary sentinel. ``cpp_main_section`` wraps the + statements between two markers in an IIFE to shorten temporary + lifetimes and bound peak setup-time stack. Emits no C++ output. - The marker produces no C++ output of its own; its ``__str__`` is - only used for debugging (e.g. ``repr`` of ``main_statements``). - The component name is retained so tooling can inspect grouping if - needed, but the generated ``main.cpp`` carries no per-component - labels — partitioning is best-effort anyway because - ``CORE.flush_tasks`` can interleave coroutines on each ``await`` - (e.g. ``cg.get_variable``) and re-schedule by priority, which - means a component's later statements can land between a different - component's earlier statements. This is semantically safe because - every statement either uses placement new into static storage or - mutates a global already declared at file scope.""" + Grouping is best-effort: ``flush_tasks`` can interleave coroutines + on ``await``, so a component's later statements may land in another + component's chunk. Safe because every statement either placement- + news into static storage or mutates a file-scope global.""" __slots__ = ("name",) From e26ce59797c3209ed64a661a3897c538662489d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 16:18:39 -0500 Subject: [PATCH 14/14] for progmem --- esphome/cpp_generator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 853025b4aa..a9dca970b6 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -477,7 +477,13 @@ def progmem_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") assignment = ProgmemAssignmentExpression(id_.type, id_, rhs) - CORE.add(assignment) + # Emit at file scope, not inside setup(). setup() is split into + # per-component IIFE lambdas; a function-local static declared in one + # lambda is not visible to statements in sibling lambdas that + # reference the same shared table (e.g. two lights sharing a gamma + # lookup). File-scope static constexpr is semantically identical for + # read-only lookup tables. + CORE.add_global(assignment) CORE.register_variable(id_, obj) return obj @@ -486,7 +492,7 @@ def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") assignment = StaticConstAssignmentExpression(id_.type, id_, rhs) - CORE.add(assignment) + CORE.add_global(assignment) CORE.register_variable(id_, obj) return obj