[core] Tidy the controller dispatch after review

Move ControllerContract next to its only user in controller_dispatch.h,
collapse the contract check into one static_assert, and let codegen
build the tuple with ArrayInitializer after emitting the tuple include.
Share one esphome.h exclusion set between the writer and the clang-tidy
all-headers file so the dispatch header is skipped by both. Add unit
tests for the generated dispatch.
This commit is contained in:
J. Nick Koston
2026-09-24 17:12:32 +01:00
parent 4b9906c5c9
commit 207edd0522
8 changed files with 78 additions and 65 deletions
+1 -1
View File
@@ -484,7 +484,7 @@ class WebServer final : public Component, public AsyncWebHandler {
#endif
#ifdef USE_MEDIA_PLAYER
void on_media_player_update(media_player::MediaPlayer *obj) {}
void on_media_player_update(media_player::MediaPlayer *) {}
#endif
#ifdef USE_EVENT
+1 -6
View File
@@ -1210,12 +1210,7 @@ class EsphomeCore:
self.platform_counts[platform_name] = 1
def register_controller(self, controller: "MockObj") -> None:
"""Register a controller that receives every entity state update.
Code generation defines the ControllerRegistry notify functions in
main.cpp as direct calls on each registered controller, so the C++
class only needs the on_*_update methods, not a base class.
"""
"""Register a controller that receives every entity state update."""
self.data.setdefault(KEY_CONTROLLER_REGISTRY_CONTROLLERS, []).append(controller)
@property
+5 -3
View File
@@ -676,15 +676,17 @@ async def _add_platform_defines() -> None:
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_controller_registry_dispatch() -> None:
# controller_dispatch.h defines ControllerRegistry::notify_*() as direct
# calls on the controllers returned by esphome_controllers().
# calls on the controllers returned by esphome_controllers(), emitted as
# static auto esphome_controllers() { return std::tuple{a, b}; }
controllers = CORE.data.get(KEY_CONTROLLER_REGISTRY_CONTROLLERS)
if not controllers:
return
cg.add_define("USE_CONTROLLER_REGISTRY")
entries = ", ".join(str(var) for var in controllers)
controllers = cg.ArrayInitializer(*controllers)
cg.add_global(cg.RawStatement("#include <tuple>"))
cg.add_global(
cg.RawStatement(
f"static auto esphome_controllers() {{ return std::tuple{{{entries}}}; }}"
f"static auto esphome_controllers() {{ return std::tuple{controllers}; }}"
)
)
cg.add_global(cg.RawStatement('#include "esphome/core/controller_dispatch.h"'))
+24 -15
View File
@@ -6,26 +6,36 @@
// #include "esphome/core/controller_dispatch.h"
//
// Defines ControllerRegistry::notify_*() as direct calls on those controllers. Excluded from
// esphome.h so nothing else includes it.
#include "esphome/core/controller_registry.h"
#ifdef USE_CONTROLLER_REGISTRY
// esphome.h and the clang-tidy all-headers file, so nothing else includes it.
#include <tuple>
#include <type_traits>
#include "esphome/core/controller_registry.h"
namespace esphome {
template<typename... Controllers> constexpr bool controllers_satisfy_contract(std::tuple<Controllers...> *) {
static_assert((ControllerContract<std::remove_pointer_t<Controllers>> && ...),
"A registered controller is missing an on_*_update() callback for an entity type in this build "
"(esphome/core/controller_registry.h)");
return true;
}
static_assert(controllers_satisfy_contract(static_cast<decltype(esphome_controllers()) *>(nullptr)));
// NOLINTBEGIN(bugprone-macro-parentheses)
/// A controller provides a plain on_*_update() member for every entity type in the build.
template<typename T>
concept ControllerContract = requires(T &controller) {
controller; // keeps the requirement list non-empty when no entity type has a callback
#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
controller.on_##callback(static_cast<type *>(nullptr));
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
};
template<typename... Controllers> constexpr bool controllers_satisfy_contract(std::tuple<Controllers...> *) {
return (ControllerContract<std::remove_pointer_t<Controllers>> && ...);
}
static_assert(controllers_satisfy_contract(static_cast<decltype(esphome_controllers()) *>(nullptr)),
"A registered controller is missing an on_*_update() callback for an entity type in this build "
"(ControllerContract in esphome/core/controller_dispatch.h)");
#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
void ControllerRegistry::notify_##callback(type *obj) { \
@@ -34,8 +44,7 @@ static_assert(controllers_satisfy_contract(static_cast<decltype(esphome_controll
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
} // namespace esphome
#endif // USE_CONTROLLER_REGISTRY
+3 -19
View File
@@ -8,28 +8,12 @@
namespace esphome {
/// A controller provides a plain on_*_update() member for every entity type in the build.
/// Checked by a static_assert in the generated main.cpp for each registered controller.
// NOLINTBEGIN(bugprone-macro-parentheses)
template<typename T>
concept ControllerContract = requires(T &controller) {
controller;
#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
controller.on_##callback(static_cast<type *>(nullptr));
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
};
// NOLINTEND(bugprone-macro-parentheses)
/** Fan-out of entity state updates to the controllers (APIServer, WebServer).
*
* Entities call ControllerRegistry::notify_*_update() instead of holding
* per-entity controller callbacks. The notify functions are only declared here;
* code generation defines them in main.cpp as direct calls on each controller
* that registered through CORE.register_controller(), so there is no virtual
* dispatch, no controller base class and no runtime list of controllers.
* per-entity controller callbacks. The functions are only declared here;
* controller_dispatch.h, included by the generated main.cpp, defines them as
* direct calls on the controllers registered through CORE.register_controller().
*/
class ControllerRegistry {
public:
+11 -16
View File
@@ -211,7 +211,16 @@ VERSION_H_TARGET = "esphome/core/version.h"
BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h"
BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp"
ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h"
CONTROLLER_DISPATCH_H_TARGET = "esphome/core/controller_dispatch.h"
# Headers that must not be included bare from esphome.h or the clang-tidy
# all-headers file: X-macro files, headers main.cpp includes itself, and
# deprecated headers that only resolve when their new component is loaded.
ESPHOME_H_EXCLUDE = {
Path(ENTITY_TYPES_H_TARGET),
# main.cpp includes it after defining esphome_controllers()
Path("esphome/core/controller_dispatch.h"),
# moved to components/ring_buffer/, removed in 2026.11.0
Path("esphome/core/ring_buffer.h"),
}
ESPHOME_README_TXT = """
THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY
@@ -237,23 +246,9 @@ def copy_src_tree():
source_files_l.sort()
# Build #include list for esphome.h
# X-macro files are included multiple times with different macro definitions
# and must not be included bare in esphome.h
# Deprecated headers that re-export from a relocated component must not be
# auto-included, since their #include of the new path only resolves when the
# new component is loaded by a consumer.
esphome_h_exclude = {
Path(ENTITY_TYPES_H_TARGET),
Path(
CONTROLLER_DISPATCH_H_TARGET
), # included by main.cpp once ESPHOME_CONTROLLERS is defined
Path(
"esphome/core/ring_buffer.h"
), # moved to components/ring_buffer/, removed in 2026.11.0
}
include_l = []
for target, _ in source_files_l:
if target.suffix in HEADER_FILE_EXTENSIONS and target not in esphome_h_exclude:
if target.suffix in HEADER_FILE_EXTENSIONS and target not in ESPHOME_H_EXCLUDE:
include_l.append(f'#include "{target}"')
include_l.append("")
include_s = "\n".join(include_l)
+2 -4
View File
@@ -429,11 +429,9 @@ def build_all_include(header_files: list[str] | None = None) -> None:
if line
]
from esphome.writer import ENTITY_TYPES_H_TARGET
from esphome.writer import ESPHOME_H_EXCLUDE
# X-macro files are included multiple times with different macro definitions
# and must not be included bare in the all-include header
exclude = {ENTITY_TYPES_H_TARGET}
exclude = {str(path) for path in ESPHOME_H_EXCLUDE}
headers = [f'#include "{h}"' for h in header_files if h not in exclude]
headers.sort()
headers.append("")
+31 -1
View File
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import config_validation as cv, core
import esphome.codegen as cg
from esphome.components.safe_mode import to_code as safe_mode_to_code
from esphome.const import (
CONF_AREA,
@@ -23,7 +24,7 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE, config
from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config
from esphome.core.config import (
Area,
make_app_name_cpp,
@@ -455,6 +456,35 @@ async def test_add_looping_components_with_entries() -> None:
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."""
CORE.data.pop(KEY_CONTROLLER_REGISTRY_CONTROLLERS, None)
await config._add_controller_registry_dispatch()
assert "USE_CONTROLLER_REGISTRY" not in {d.name for d in CORE.defines}
assert not [s for s in CORE.global_statements if "controller" in str(s)]
@pytest.mark.asyncio
async def test_add_controller_registry_dispatch_with_controllers() -> None:
"""Registered controllers become one tuple plus the dispatch include."""
CORE.register_controller(cg.MockObj("api_apiserver_id"))
CORE.register_controller(cg.MockObj("web_server_webserver_id"))
await config._add_controller_registry_dispatch()
assert "USE_CONTROLLER_REGISTRY" in {d.name for d in CORE.defines}
statements = [str(s) for s in CORE.global_statements]
assert "#include <tuple>" in statements
assert (
"static auto esphome_controllers() { return std::tuple{api_apiserver_id, web_server_webserver_id}; }"
in statements
)
assert '#include "esphome/core/controller_dispatch.h"' in statements
def test_valid_include_with_angle_brackets() -> None:
"""Test valid_include accepts angle bracket includes."""
assert valid_include("<ArduinoJson.h>") == "<ArduinoJson.h>"