[core] Compile-time detection of loop() overrides (#14405)

This commit is contained in:
J. Nick Koston
2026-03-02 06:59:23 -10:00
committed by GitHub
parent 585e195044
commit a1d91ac779
7 changed files with 73 additions and 28 deletions

View File

@@ -79,7 +79,12 @@ static void insertion_sort_by_priority(Iterator first, Iterator last) {
}
}
void Application::register_component_(Component *comp) { this->components_.push_back(comp); }
void Application::register_component_impl_(Component *comp, bool has_loop) {
if (has_loop) {
comp->component_state_ |= COMPONENT_HAS_LOOP;
}
this->components_.push_back(comp);
}
void Application::setup() {
ESP_LOGI(TAG, "Running through setup()");
ESP_LOGV(TAG, "Sorting components by setup priority");
@@ -382,16 +387,8 @@ void Application::teardown_components(uint32_t timeout_ms) {
}
void Application::calculate_looping_components_() {
// Count total components that need looping
size_t total_looping = 0;
for (auto *obj : this->components_) {
if (obj->has_overridden_loop()) {
total_looping++;
}
}
// Initialize FixedVector with exact size - no reallocation possible
this->looping_components_.init(total_looping);
// FixedVector capacity was pre-initialized by codegen with the exact count
// of components that override loop(), computed at C++ compile time.
// Add all components with loop override that aren't already LOOP_DONE
// Some components (like logger) may call disable_loop() during initialization

View File

@@ -5,6 +5,7 @@
#include <limits>
#include <span>
#include <string>
#include <type_traits>
#include <vector>
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
@@ -542,7 +543,14 @@ class Application {
#endif
#endif
void register_component_(Component *comp);
/// Register a component, detecting loop() override at compile time.
/// The template resolves &T::loop vs &Component::loop as a constexpr bool
/// and forwards it to register_component_impl_ which stores it in component_state_.
template<typename T> void register_component_(T *comp) {
this->register_component_impl_(comp, !std::is_same_v<decltype(&T::loop), decltype(&Component::loop)>);
}
void register_component_impl_(Component *comp, bool has_loop);
void calculate_looping_components_();
void add_looping_components_by_state_(bool match_loop_done);

View File

@@ -496,18 +496,6 @@ void Component::set_setup_priority(float priority) {
}
#endif
bool Component::has_overridden_loop() const {
#if defined(USE_HOST) || defined(CLANG_TIDY)
return true;
#else
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wpmf-conversions"
bool loop_overridden = (void *) (this->*(&Component::loop)) != (void *) (&Component::loop);
#pragma GCC diagnostic pop
return loop_overridden;
#endif
}
PollingComponent::PollingComponent(uint32_t update_interval) : update_interval_(update_interval) {}
void PollingComponent::call_setup() {

View File

@@ -76,6 +76,8 @@ inline constexpr uint8_t STATUS_LED_MASK = 0x18;
inline constexpr uint8_t STATUS_LED_OK = 0x00;
inline constexpr uint8_t STATUS_LED_WARNING = 0x08;
inline constexpr uint8_t STATUS_LED_ERROR = 0x10;
// Component loop override flag uses bit 5 (set at registration time)
inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20;
// Remove before 2026.8.0
enum class RetryResult { DONE, RETRY };
@@ -271,7 +273,7 @@ class Component {
*/
void status_momentary_error(const char *name, uint32_t length = 5000);
bool has_overridden_loop() const;
bool has_overridden_loop() const { return (this->component_state_ & COMPONENT_HAS_LOOP) != 0; }
/** Set where this component was loaded from for some debug messages.
*
@@ -510,7 +512,8 @@ class Component {
/// Bits 0-2: Component state (0x00=CONSTRUCTION, 0x01=SETUP, 0x02=LOOP, 0x03=FAILED, 0x04=LOOP_DONE)
/// Bit 3: STATUS_LED_WARNING
/// Bit 4: STATUS_LED_ERROR
/// Bits 5-7: Unused - reserved for future expansion
/// Bit 5: Has overridden loop() (set at registration time)
/// Bits 6-7: Unused - reserved for future expansion
uint8_t component_state_{0x00};
volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context
};

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from collections import Counter
import logging
import os
from pathlib import Path
@@ -504,6 +505,40 @@ async def _add_controller_registry_define() -> None:
cg.add_define("CONTROLLER_REGISTRY_MAX", controller_count)
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_looping_components() -> None:
# Emit a constexpr that computes the looping component count at C++ compile time
# and pre-init the FixedVector with the exact capacity. Uses std::is_same_v to
# detect loop() overrides. The constexpr goes in main.cpp's global section where
# all component types are in scope. calculate_looping_components_() then skips
# the counting pass and only does the two population passes.
entries = CORE.data.get("looping_component_entries", [])
if not entries:
return
# Build constexpr sum for the exact count, deduplicating by type
type_counts = Counter(entries)
terms = [
f"({count} * !std::is_same_v<decltype(&{cpp_type}::loop), decltype(&Component::loop)>)"
for cpp_type, count in type_counts.items()
]
constexpr_expr = " + \\\n ".join(terms)
cg.add_global(
cg.RawStatement(
f"static constexpr size_t ESPHOME_LOOPING_COMPONENT_COUNT = \\\n"
f" {constexpr_expr};"
)
)
# Pre-init FixedVector with exact capacity so calculate_looping_components_()
# can skip the counting pass
cg.add(
cg.RawExpression(
"App.looping_components_.init(ESPHOME_LOOPING_COMPONENT_COUNT)"
)
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config: ConfigType) -> None:
cg.add_global(cg.global_ns.namespace("esphome").using)
@@ -527,6 +562,7 @@ async def to_code(config: ConfigType) -> None:
CORE.add_job(_add_platform_defines)
CORE.add_job(_add_controller_registry_define)
CORE.add_job(_add_looping_components)
CORE.add_job(_add_automations, config)

View File

@@ -80,6 +80,11 @@ async def register_component(var, config):
add(var.set_component_source(LogStringLiteral(name)))
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))
return var

View File

@@ -14,7 +14,11 @@ async def test_gpio_pin_expression__conf_is_none(monkeypatch):
@pytest.mark.asyncio
async def test_register_component(monkeypatch):
var = Mock(base="foo.bar")
base_mock = Mock()
base_mock.__str__ = lambda self: "foo.bar"
base_mock.type = Mock()
base_mock.type.__str__ = lambda self: "foo::Bar"
var = Mock(base=base_mock)
app_mock = Mock(register_component_=Mock(return_value=var))
monkeypatch.setattr(ch, "App", app_mock)
@@ -46,7 +50,11 @@ async def test_register_component__no_component_id(monkeypatch):
@pytest.mark.asyncio
async def test_register_component__with_setup_priority(monkeypatch):
var = Mock(base="foo.bar")
base_mock = Mock()
base_mock.__str__ = lambda self: "foo.bar"
base_mock.type = Mock()
base_mock.type.__str__ = lambda self: "foo::Bar"
var = Mock(base=base_mock)
app_mock = Mock(register_component_=Mock(return_value=var))
monkeypatch.setattr(ch, "App", app_mock)