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/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/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/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f86..8bc4e6e153 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -531,6 +531,42 @@ class Library: return self +def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: + """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. + + 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 + + def flush() -> None: + if not chunk: + return + 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: + chunk.append(line) + # 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() + flush() + return out + + # pylint: disable=too-many-public-methods class EsphomeCore: def __init__(self): @@ -1003,14 +1039,29 @@ 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 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 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 = [] + components.append(current) + continue + current.append(str(statement(exp)).rstrip()) + + if not components: + return "\n".join(prefix) + "\n\n" + + pieces = list(prefix) + for body in components: + pieces.extend(_wrap_in_iifes(body, 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..a9dca970b6 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -434,6 +434,25 @@ class LineComment(Statement): return "\n".join(parts) +class ComponentMarker(Statement): + """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. + + 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",) + + def __init__(self, name: str): + self.name = name + + def __str__(self): + return f"// component-marker: {self.name}" + + class ProgmemAssignmentExpression(AssignmentExpression): __slots__ = () @@ -458,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 @@ -467,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 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 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 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 diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 22be59653a..e4a52a7808 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,170 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + +def test_wrap_in_iifes_empty_input() -> None: + assert core._wrap_in_iifes([], max_statements=10) == [] + + +def test_wrap_in_iifes_fewer_lines_than_limit() -> None: + lines = ["a();", "b();", "c();"] + assert core._wrap_in_iifes(lines, max_statements=10) == [ + "[]() {", + "a();", + "b();", + "c();", + "}();", + ] + + +def test_wrap_in_iifes_splits_at_max_statements() -> None: + lines = [f"s{i}();" for i in range(5)] + 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_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_iifes(lines, max_statements=2) == [ + "[]() {", + "a();", + "{", + "inner();", + "}", + "}();", + "[]() {", + "b();", + "}();", + ] + + +def test_wrap_in_iifes_nested_braces() -> None: + lines = ["{", "{", "deep();", "}", "}", "after();"] + assert core._wrap_in_iifes(lines, max_statements=1) == [ + "[]() {", + "{", + "{", + "deep();", + "}", + "}", + "}();", + "[]() {", + "after();", + "}();", + ] + + +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_iifes(lines, max_statements=1) + assert result[0] == "[]() {" + assert result[-1] == "}();" + 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: + # 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 + + +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 "[]() {" 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 + # One IIFE per component that emits C++ statements. + assert out.count("[]() {") == 2 + assert out.count("}();") == 2 + # ComponentMarker produces no output of its own. + assert "component-marker" not in out + + +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"), + ComponentMarker("wifi"), + RawStatement("new_wifi();"), + ] + out = target.cpp_main_section + assert out.count("[]() {") == 1 + assert "new_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("[]() {")