Compare commits

...
Author SHA1 Message Date
J. Nick Koston 95478bf221 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:39:02 -06:00
J. Nick Koston fd3feebd7c Merge dev; emit the looping components storage from add_component_counts 2026-11-09 08:54:13 -08:00
J. Nick Koston 276e113679 Merge remote-tracking branch 'origin/dev' into core-component-count-registered
# Conflicts:
#	tests/unit_tests/test_cpp_helpers.py
2026-11-04 21:23:30 -06:00
J. Nick Koston 949f210c1c Merge branch 'dev' into core-component-count-registered 2026-10-29 12:55:01 -10:00
J. Nick Koston 8ced42a7af [core] Emit both component counts from the registered component list
ESPHOME_LOOPING_COMPONENT_COUNT now comes from the same list of
registered component types and the same late job as
ESPHOME_COMPONENT_COUNT, so a component registered from a FINAL job
is counted in both. Check the late-registration guard before removing
the id from CORE.component_ids.
2026-10-28 22:17:11 -10:00
J. Nick Koston c8503aa4a4 [core] Size ESPHOME_COMPONENT_COUNT from registered components
Count register_component() calls and emit the define from a job that
runs after every other codegen job, instead of counting every declared
id whose type inherits Component. Ids that are declared but never
registered no longer reserve a slot in App.components_.
2026-10-28 22:08:45 -10:00
4 changed files with 168 additions and 95 deletions

No files matched your search

+3 -35
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
from collections import Counter
import logging
import os
from pathlib import Path
@@ -60,6 +59,7 @@ from esphome.core import (
TimePeriodMilliseconds,
coroutine_with_priority,
)
from esphome.cpp_helpers import add_component_counts
import esphome.final_validate as fv
from esphome.helpers import (
copy_file_if_changed,
@@ -843,36 +843,6 @@ async def _add_controller_registry_dispatch() -> None:
cg.add_global(cg.RawStatement('#include "esphome/core/controller_dispatch.h"'))
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_looping_components() -> None:
# Emit ESPHOME_LOOPING_COMPONENT_COUNT and its storage. Sizing of looping_components_
# happens in core to_code() so it lands before safe_mode's early return.
entries = CORE.data.get("looping_component_entries", [])
# Build constexpr sum for the exact count, deduplicating by type
# Uses HasLoopOverride<T> which handles ambiguous &T::loop from multiple inheritance
type_counts = Counter(entries)
terms = [
f"({count} * HasLoopOverride<{cpp_type}>::value)"
for cpp_type, count in type_counts.items()
] or ["0"]
constexpr_expr = " + \\\n ".join(terms)
cg.add_global(
cg.RawStatement(
f"static constexpr size_t ESPHOME_LOOPING_COMPONENT_COUNT = \\\n"
f" {constexpr_expr};"
)
)
# In namespace esphome so analyze-memory counts it as core; sized at least 1 to avoid a zero-length array
cg.add_global(
cg.RawStatement(
"namespace esphome {\n"
"static Component *looping_components_storage[std::max<size_t>(ESPHOME_LOOPING_COMPONENT_COUNT, 1)];\n"
"} // namespace esphome"
)
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config: ConfigType) -> None:
# using namespace esphome is hardcoded in writer.py to guarantee it
@@ -912,11 +882,10 @@ async def to_code(config: ConfigType) -> None:
if name_add_mac_suffix:
cg.add_define("ESPHOME_NAME_ADD_MAC_SUFFIX")
cg.add(cg.App.pre_setup(name_expr, name_len, friendly_expr, friendly_len))
# Define component count for static allocation
cg.add_define("ESPHOME_COMPONENT_COUNT", len(CORE.component_ids))
CORE.add_job(add_component_counts)
# Pre-init the list with exact capacity so calculate_looping_components_()
# can skip the counting pass; the storage is emitted by _add_looping_components()
# can skip the counting pass; the storage is emitted by add_component_counts()
cg.add(
cg.RawExpression(
"App.looping_components_.init(std::span<Component *>("
@@ -926,7 +895,6 @@ async def to_code(config: ConfigType) -> None:
CORE.add_job(_add_platform_defines)
CORE.add_job(_add_controller_registry_dispatch)
CORE.add_job(_add_looping_components)
CORE.add_job(_add_automations, config)
+59 -3
View File
@@ -1,3 +1,4 @@
from collections import Counter
from collections.abc import Awaitable, Callable, Hashable
from dataclasses import dataclass, field
import logging
@@ -60,6 +61,58 @@ def _get_source_pool() -> ComponentSourcePool:
return CORE.data[_COMPONENT_SOURCE_DOMAIN]
_REGISTERED_COMPONENTS_DOMAIN = "registered_components"
# Below every finite priority, so the job runs only once every other job,
# including FINAL ones and to_code tasks pushed down by waiting, is done.
_AFTER_ALL_JOBS = float("-inf")
@dataclass
class _RegisteredComponents:
"""C++ type of each register_component() call, which sizes App.components_
and App.looping_components_."""
types: list[str] = field(default_factory=list)
emitted: bool = False
def _get_registered_components() -> _RegisteredComponents:
if _REGISTERED_COMPONENTS_DOMAIN not in CORE.data:
CORE.data[_REGISTERED_COMPONENTS_DOMAIN] = _RegisteredComponents()
return CORE.data[_REGISTERED_COMPONENTS_DOMAIN]
@coroutine_with_priority(_AFTER_ALL_JOBS)
async def add_component_counts() -> None:
"""Emit ESPHOME_COMPONENT_COUNT and ESPHOME_LOOPING_COMPONENT_COUNT from the
components actually registered; declared but unregistered ids take no slot."""
registered = _get_registered_components()
registered.emitted = True
add_define("ESPHOME_COMPONENT_COUNT", len(registered.types))
# Exact looping count, summed per type at C++ compile time. HasLoopOverride<T>
# handles an ambiguous &T::loop from multiple inheritance.
terms = [
f"({count} * HasLoopOverride<{cpp_type}>::value)"
for cpp_type, count in Counter(registered.types).items()
] or ["0"]
constexpr_expr = " + \\\n ".join(terms)
add_global(
RawStatement(
f"static constexpr size_t ESPHOME_LOOPING_COMPONENT_COUNT = \\\n"
f" {constexpr_expr};"
)
)
# In namespace esphome so analyze-memory counts it as core; at least 1 to avoid a zero-length array
add_global(
RawStatement(
"namespace esphome {\n"
"static Component *looping_components_storage[std::max<size_t>(ESPHOME_LOOPING_COMPONENT_COUNT, 1)];\n"
"} // namespace esphome"
)
)
def _ensure_source_table_registered() -> None:
"""Schedule the table generation job (once)."""
pool = _get_source_pool()
@@ -441,6 +494,11 @@ async def register_component(var, config):
raise ValueError(
f"Component ID {id_} was not declared to inherit from Component, or was registered twice. Please create a bug report with your configuration."
)
registered = _get_registered_components()
if registered.emitted:
raise ValueError(
f"Component ID {id_} was registered after ESPHOME_COMPONENT_COUNT was emitted. Please create a bug report with your configuration."
)
CORE.component_ids.remove(id_)
if CONF_SETUP_PRIORITY in config:
set_setup_priority(var, config[CONF_SETUP_PRIORITY])
@@ -475,9 +533,7 @@ async def register_component(var, config):
else:
add(App.register_component_(var))
# Collect C++ type for compile-time looping component count
comp_entries = CORE.data.setdefault("looping_component_entries", [])
comp_entries.append(str(var.base.type))
registered.types.append(str(var.base.type))
return var
-57
View File
@@ -707,63 +707,6 @@ def test_to_code_priority_above_safe_mode() -> None:
)
@pytest.mark.asyncio
async def test_add_looping_components_handles_empty_entries() -> None:
"""Test that _add_looping_components emits a valid constexpr when there are
no looping component entries.
With zero entries the generated constexpr must still be syntactically valid
C++ (`= 0;`), not an empty expression (`= ;`). This guards the empty-list
case that would otherwise produce uncompilable main.cpp output.
"""
CORE.data["looping_component_entries"] = []
await config._add_looping_components()
constexpr_lines = [
str(s)
for s in CORE.global_statements
if "constexpr size_t ESPHOME_LOOPING_COMPONENT_COUNT" in str(s)
]
assert len(constexpr_lines) == 1
text = constexpr_lines[0]
assert "static constexpr size_t ESPHOME_LOOPING_COMPONENT_COUNT" in text
# The right-hand side must contain a literal `0`, not be empty.
rhs = text.split("=", 1)[1]
assert "0" in rhs
assert rhs.strip().rstrip(";").strip(), (
f"constexpr right-hand side must not be empty, got: {text!r}"
)
assert any(
"static Component *looping_components_storage[std::max<size_t>(ESPHOME_LOOPING_COMPONENT_COUNT, 1)];"
in str(s)
for s in CORE.global_statements
)
@pytest.mark.asyncio
async def test_add_looping_components_with_entries() -> None:
"""Test that _add_looping_components builds a HasLoopOverride sum from entries."""
CORE.data["looping_component_entries"] = [
"esphome::wifi::WiFiComponent",
"esphome::logger::Logger",
"esphome::wifi::WiFiComponent",
]
await config._add_looping_components()
constexpr_lines = [
str(s)
for s in CORE.global_statements
if "constexpr size_t ESPHOME_LOOPING_COMPONENT_COUNT" in str(s)
]
assert len(constexpr_lines) == 1
text = constexpr_lines[0]
# Deduplicated by type, with per-type counts as multiplier.
assert "(2 * HasLoopOverride<esphome::wifi::WiFiComponent>::value)" in text
assert "(1 * HasLoopOverride<esphome::logger::Logger>::value)" in text
@pytest.mark.asyncio
async def test_add_controller_registry_dispatch_without_controllers() -> None:
"""Nothing is emitted when no controller registered."""
+106
View File
@@ -12,6 +12,7 @@ import pytest
from esphome import codegen as cg, const, cpp_helpers as ch, cpp_types as ct
from esphome.core import CoroPriority, coroutine_with_priority
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import MockObj
from esphome.cpp_helpers import ComponentSourcePool, register_component_source
@@ -253,6 +254,111 @@ def test_slot_counter_request_after_emit_raises() -> None:
request()
def _component_var(id_: str, cpp_type: str = "foo::Bar") -> Mock:
base = Mock()
base.__str__ = lambda self: id_
base.type = Mock()
base.type.__str__ = lambda self: cpp_type
return Mock(base=base)
def _looping_count_statement() -> str:
lines = [
str(s)
for s in ch.CORE.global_statements
if "constexpr size_t ESPHOME_LOOPING_COMPONENT_COUNT" in str(s)
]
assert len(lines) == 1
return lines[0]
@pytest.fixture
def stub_component_codegen(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep the real CORE (jobs, defines) but skip emitting C++ statements."""
monkeypatch.setattr(ch, "add", Mock())
monkeypatch.setattr(ch, "App", Mock())
@pytest.mark.usefixtures("stub_component_codegen")
@pytest.mark.parametrize(
("declared", "registered", "expected"),
[
({"comp_a", "comp_b", "declared_only"}, ["comp_a", "comp_b"], "2"),
({"declared_only"}, [], "0"),
],
)
def test_component_count_skips_unregistered_ids(
declared: set[str], registered: list[str], expected: str
) -> None:
"""An id declared with a Component type but never registered takes no slot."""
ch.CORE.component_ids.update(declared)
async def registrar() -> None:
for id_ in registered:
await ch.register_component(_component_var(id_), {})
ch.CORE.add_job(ch.add_component_counts)
ch.CORE.add_job(registrar)
ch.CORE.flush_tasks()
assert _define_value("ESPHOME_COMPONENT_COUNT") == expected
@pytest.mark.usefixtures("stub_component_codegen")
def test_looping_component_count_without_components_is_zero() -> None:
"""With no components the constexpr is still valid C++ (`= 0;`, not `= ;`)."""
ch.CORE.add_job(ch.add_component_counts)
ch.CORE.flush_tasks()
rhs = _looping_count_statement().split("=", 1)[1]
assert rhs.replace("\\\n", "").strip() == "0;"
# The storage is sized at least 1 so a zero count is not a zero-length array
assert any(
"static Component *looping_components_storage[std::max<size_t>(ESPHOME_LOOPING_COMPONENT_COUNT, 1)];"
in str(s)
for s in ch.CORE.global_statements
)
@pytest.mark.usefixtures("stub_component_codegen")
def test_component_counts_include_final_and_waiting_registrations() -> None:
"""Registrations from FINAL jobs, and from tasks that waited long enough
to sink below FINAL, land in both counts before they are emitted."""
ch.CORE.component_ids.update({"final_comp", "slow_comp", "slow_comp_2"})
@coroutine_with_priority(CoroPriority.FINAL)
async def final_registrar() -> None:
await ch.register_component(_component_var("final_comp", "foo::Final"), {})
async def slow_registrar() -> None:
# Each yield lowers the task's priority by one, 1500 puts it below FINAL.
for _ in range(1500):
await FakeAwaitable(iter([None]))
await ch.register_component(_component_var("slow_comp", "foo::Slow"), {})
await ch.register_component(_component_var("slow_comp_2", "foo::Slow"), {})
ch.CORE.add_job(ch.add_component_counts)
ch.CORE.add_job(final_registrar)
ch.CORE.add_job(slow_registrar)
ch.CORE.flush_tasks()
assert _define_value("ESPHOME_COMPONENT_COUNT") == "3"
# Summed per type, so each type is checked once at compile time.
looping = _looping_count_statement()
assert "(1 * HasLoopOverride<foo::Final>::value)" in looping
assert "(2 * HasLoopOverride<foo::Slow>::value)" in looping
@pytest.mark.asyncio
@pytest.mark.usefixtures("stub_component_codegen")
async def test_component_count_register_after_emit_raises() -> None:
"""A registration after the counts were emitted would be dropped from
App.components_ at run time, so it fails loudly and leaves the id alone."""
ch.CORE.component_ids.add("too_late")
ch.CORE.add_job(ch.add_component_counts)
ch.CORE.flush_tasks()
with pytest.raises(ValueError, match="after ESPHOME_COMPONENT_COUNT"):
await ch.register_component(_component_var("too_late"), {})
assert "too_late" in ch.CORE.component_ids
def _load_module(
monkeypatch: pytest.MonkeyPatch, name: str, path: Path, source: str
) -> ModuleType: