From c0c02e5c57acb3e7602da0c2d6c03814f11f07f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2026 07:12:19 -1000 Subject: [PATCH 1/3] Make setup_entity work as both decorator and direct call Address review feedback: support both @setup_entity("sensor") decorator mode and await setup_entity(var, config, "camera") direct-call mode to maintain backward compatibility with external components that use the direct-call API. Simplify esp32_camera to use the direct-call form instead of a no-op decorated wrapper function. --- esphome/components/esp32_camera/__init__.py | 7 +--- esphome/core/entity_helpers.py | 43 +++++++++++++-------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 22e14ae9ee..3a5d87792b 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -368,16 +368,11 @@ SETTERS = { } -@setup_entity("camera") -async def _setup_esp32_camera(var, config): - pass - - async def to_code(config): cg.add_define("USE_CAMERA") socket.require_wake_loop_threadsafe() var = cg.new_Pvariable(config[CONF_ID]) - await _setup_esp32_camera(var, config) + await setup_entity(var, config, "camera") await cg.register_component(var, config) for key, setter in SETTERS.items(): diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index fd151d82d9..ac68eaa64b 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -236,33 +236,44 @@ def get_base_entity_object_id( return sanitize(snake_case(base_str)) -def setup_entity(platform: str) -> Callable: - """Decorator for component setup functions. +def setup_entity(var_or_platform, config=None, platform=None): + """Set up entity properties — works as both decorator and direct call. - Wraps the function to: - 1. Set up common entity properties (name, icon, etc.) - 2. Run the wrapped function (which may call setup_device_class, etc.) - 3. Finalize entity strings (pack dc/uom/icon indices into uint32_t) - - Usage:: + Decorator mode:: @setup_entity("sensor") async def setup_sensor_core_(var, config): setup_device_class(config) setup_unit_of_measurement(config) ... + + Direct call mode (for entities with no extra string properties):: + + await setup_entity(var, config, "camera") """ + if isinstance(var_or_platform, str) and config is None: + # Decorator mode: @setup_entity("sensor") + platform = var_or_platform - def decorator(func: Callable) -> Callable: - @functools.wraps(func) - async def wrapper(var: MockObj, config: ConfigType, *args, **kwargs) -> None: - await _setup_entity_impl(var, config, platform) - await func(var, config, *args, **kwargs) - finalize_entity_strings(var, config) + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper( + var: MockObj, config: ConfigType, *args, **kwargs + ) -> None: + await _setup_entity_impl(var, config, platform) + await func(var, config, *args, **kwargs) + finalize_entity_strings(var, config) - return wrapper + return wrapper - return decorator + return decorator + + # Direct call mode: await setup_entity(var, config, "camera") + async def _do(): + await _setup_entity_impl(var_or_platform, config, platform) + finalize_entity_strings(var_or_platform, config) + + return _do() async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> None: From d7bb574d267b15bf91ba922aa58c403e45fc7b71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2026 07:13:00 -1000 Subject: [PATCH 2/3] Add return type hint to _do() --- esphome/core/entity_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index ac68eaa64b..551e35df65 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -269,7 +269,7 @@ def setup_entity(var_or_platform, config=None, platform=None): return decorator # Direct call mode: await setup_entity(var, config, "camera") - async def _do(): + async def _do() -> None: await _setup_entity_impl(var_or_platform, config, platform) finalize_entity_strings(var_or_platform, config) From deea16698d1cc037fa3865c17c009f17c6c28622 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2026 07:13:49 -1000 Subject: [PATCH 3/3] Add tests for setup_entity direct-call and decorator modes --- tests/unit_tests/core/test_entity_helpers.py | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 5dc017a5ed..a5cfad5ab6 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -23,6 +23,7 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, get_base_entity_object_id, + setup_entity, ) from esphome.cpp_generator import MockObj from esphome.helpers import sanitize, snake_case @@ -924,3 +925,51 @@ async def test_setup_entity_with_entity_category( assert any( 'set_entity_category("diagnostic")' in expr for expr in added_expressions ) + + +@pytest.mark.asyncio +async def test_setup_entity_direct_call(setup_test_environment: list[str]) -> None: + """Test setup_entity in direct call mode (legacy / backward compat).""" + added_expressions = setup_test_environment + + var = MockObj("camera1") + config = { + CONF_NAME: "My Camera", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ICON: "mdi:camera", + } + + # Direct call mode: await setup_entity(var, config, "camera") + await setup_entity(var, config, "camera") + + # Should have called set_name + object_id = extract_object_id_from_expressions(added_expressions) + assert object_id == "my_camera" + + # Icon index should have been stored and finalized + assert config.get("_entity_icon_idx", 0) > 0 + + +@pytest.mark.asyncio +async def test_setup_entity_decorator_mode(setup_test_environment: list[str]) -> None: + """Test setup_entity in decorator mode.""" + added_expressions = setup_test_environment + + body_called = False + + @setup_entity("sensor") + async def my_setup(var, config): + nonlocal body_called + body_called = True + + var = MockObj("sensor1") + config = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: False, + } + + await my_setup(var, config) + + assert body_called + object_id = extract_object_id_from_expressions(added_expressions) + assert object_id == "temperature"