From 06d88e0381c6f26218b65086d080847a3a3bb70f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 12:08:36 +0200 Subject: [PATCH] [core][sensor][binary_sensor][text_sensor][debug][xiaomi_rtcgq02lm] Add new_sub_sensor, new_sub_binary_sensor and new_sub_text_sensor helpers (#19725) --- AGENTS.md | 10 +++++ esphome/components/binary_sensor/__init__.py | 15 ++++++- esphome/components/debug/sensor.py | 42 +++++++------------ esphome/components/debug/text_sensor.py | 12 +++--- esphome/components/sensor/__init__.py | 13 ++++++ esphome/components/text_sensor/__init__.py | 16 ++++++- .../xiaomi_rtcgq02lm/binary_sensor.py | 18 ++++---- esphome/components/xiaomi_rtcgq02lm/sensor.py | 4 +- esphome/core/entity_helpers.py | 24 ++++++++++- tests/unit_tests/core/test_entity_helpers.py | 39 +++++++++++++++++ 10 files changed, 142 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30ce6dfe9a..670cf55b03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,6 +322,16 @@ file does, and it is the authority when they disagree. The most useful starting var = await switch.new_switch(config) ``` + - **Optional child entities of a hub:** use `new_sub_sensor`, `new_sub_binary_sensor` or + `new_sub_text_sensor`. Each creates the entity only when its key is configured and passes it to + the setter. Always name the setter explicitly; never build it with `getattr` and an f-string. + ```python + async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await sensor.new_sub_sensor(config, CONF_TEMPERATURE, var.set_temperature_sensor) + await sensor.new_sub_sensor(config, CONF_HUMIDITY, var.set_humidity_sensor) + ``` + * **Automations (Triggers, Actions, Conditions):** Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 6b7c3dcb16..fb2ade82ce 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from logging import getLogger from esphome import automation, core @@ -64,11 +65,13 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + new_sub_entity, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType, Expression, SafeExpType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -632,6 +635,16 @@ async def new_binary_sensor(config, *args): return var +async def new_sub_binary_sensor( + config: ConfigType, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, +) -> MockObj | None: + """Create the binary sensor configured under key, if any, and pass it to setter.""" + return await new_sub_entity(new_binary_sensor, config, key, setter, *args) + + BINARY_SENSOR_CONDITION_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(BinarySensor), diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 80d1daa81f..35eba48d13 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -108,30 +108,18 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) - if free_conf := config.get(CONF_FREE): - sens = await sensor.new_sensor(free_conf) - cg.add(debug_component.set_free_sensor(sens)) - - if block_conf := config.get(CONF_BLOCK): - sens = await sensor.new_sensor(block_conf) - cg.add(debug_component.set_block_sensor(sens)) - - if fragmentation_conf := config.get(CONF_FRAGMENTATION): - sens = await sensor.new_sensor(fragmentation_conf) - cg.add(debug_component.set_fragmentation_sensor(sens)) - - if min_free_conf := config.get(CONF_MIN_FREE): - sens = await sensor.new_sensor(min_free_conf) - cg.add(debug_component.set_min_free_sensor(sens)) - - if loop_time_conf := config.get(CONF_LOOP_TIME): - sens = await sensor.new_sensor(loop_time_conf) - cg.add(debug_component.set_loop_time_sensor(sens)) - - if psram_conf := config.get(CONF_PSRAM): - sens = await sensor.new_sensor(psram_conf) - cg.add(debug_component.set_psram_sensor(sens)) - - if cpu_freq_conf := config.get(CONF_CPU_FREQUENCY): - sens = await sensor.new_sensor(cpu_freq_conf) - cg.add(debug_component.set_cpu_frequency_sensor(sens)) + await sensor.new_sub_sensor(config, CONF_FREE, debug_component.set_free_sensor) + await sensor.new_sub_sensor(config, CONF_BLOCK, debug_component.set_block_sensor) + await sensor.new_sub_sensor( + config, CONF_FRAGMENTATION, debug_component.set_fragmentation_sensor + ) + await sensor.new_sub_sensor( + config, CONF_MIN_FREE, debug_component.set_min_free_sensor + ) + await sensor.new_sub_sensor( + config, CONF_LOOP_TIME, debug_component.set_loop_time_sensor + ) + await sensor.new_sub_sensor(config, CONF_PSRAM, debug_component.set_psram_sensor) + await sensor.new_sub_sensor( + config, CONF_CPU_FREQUENCY, debug_component.set_cpu_frequency_sensor + ) diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index 2e02af67cb..cac7da0f23 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -33,9 +33,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) - if CONF_DEVICE in config: - sens = await text_sensor.new_text_sensor(config[CONF_DEVICE]) - cg.add(debug_component.set_device_info_sensor(sens)) - if CONF_RESET_REASON in config: - sens = await text_sensor.new_text_sensor(config[CONF_RESET_REASON]) - cg.add(debug_component.set_reset_reason_sensor(sens)) + await text_sensor.new_sub_text_sensor( + config, CONF_DEVICE, debug_component.set_device_info_sensor + ) + await text_sensor.new_sub_text_sensor( + config, CONF_RESET_REASON, debug_component.set_reset_reason_sensor + ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 62d78a32dd..5df4a9acbd 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import logging import math @@ -115,6 +116,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + new_sub_entity, queue_entity_register, setup_device_class, setup_entity, @@ -122,6 +124,7 @@ from esphome.core.entity_helpers import ( ) from esphome.cpp_generator import MockObj, MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType, Expression, SafeExpType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -1013,6 +1016,16 @@ async def new_sensor(config, *args): return var +async def new_sub_sensor( + config: ConfigType, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, +) -> MockObj | None: + """Create the sensor configured under key, if any, and pass it to setter.""" + return await new_sub_entity(new_sensor, config, key, setter, *args) + + SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All( { cv.Required(CONF_ID): cv.use_id(Sensor), diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 34c46d5451..392fe325d5 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server @@ -23,11 +25,13 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + new_sub_entity, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType, Expression, SafeExpType from esphome.util import Registry DEVICE_CLASSES = [ @@ -231,6 +235,16 @@ async def new_text_sensor(config, *args): return var +async def new_sub_text_sensor( + config: ConfigType, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, +) -> MockObj | None: + """Create the text sensor configured under key, if any, and pass it to setter.""" + return await new_sub_entity(new_text_sensor, config, key, setter, *args) + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(text_sensor_ns.using) diff --git a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py index 57420125cb..f1b01cfd61 100644 --- a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py @@ -49,16 +49,12 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) - if CONF_MOTION in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_MOTION]) - cg.add(parent.set_motion(sens)) + if await binary_sensor.new_sub_binary_sensor( + config, CONF_MOTION, parent.set_motion + ): cg.add(parent.set_motion_timeout(config[CONF_MOTION][CONF_TIMEOUT])) - - if CONF_LIGHT in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_LIGHT]) - cg.add(parent.set_light(sens)) - - if CONF_BUTTON in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_BUTTON]) - cg.add(parent.set_button(sens)) + await binary_sensor.new_sub_binary_sensor(config, CONF_LIGHT, parent.set_light) + if await binary_sensor.new_sub_binary_sensor( + config, CONF_BUTTON, parent.set_button + ): cg.add(parent.set_button_timeout(config[CONF_BUTTON][CONF_TIMEOUT])) diff --git a/esphome/components/xiaomi_rtcgq02lm/sensor.py b/esphome/components/xiaomi_rtcgq02lm/sensor.py index e0e4b4640b..587b1b594e 100644 --- a/esphome/components/xiaomi_rtcgq02lm/sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/sensor.py @@ -33,6 +33,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(parent.set_battery_level(sens)) + await sensor.new_sub_sensor(config, CONF_BATTERY_LEVEL, parent.set_battery_level) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 54e2551cb4..2956ac6001 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,7 +1,8 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field import functools import logging +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -32,7 +33,7 @@ from esphome.helpers import ( sanitize, snake_case, ) -from esphome.types import ConfigType, EntityMetadata +from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType _LOGGER = logging.getLogger(__name__) @@ -467,6 +468,25 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> config[_KEY_ICON_IDX] = icon_idx +async def new_sub_entity( + new_entity: Callable[..., Awaitable[MockObj]], + config: ConfigType, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, + **kwargs: Any, +) -> MockObj | None: + """Create the entity configured under key, if any, and pass it to setter. + + Returns None only when key is not configured, so the result can be used directly as a condition. + """ + if (conf := config.get(key)) is None: + return None + var = await new_entity(conf, *args, **kwargs) + add(setter(var)) + return var + + def inherit_property_from(property_to_inherit, parent_id_property, transform=None): """Validator that inherits a configuration property from another entity, for use with FINAL_VALIDATE_SCHEMA. If a property is already set, it will not be inherited. diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 53035ad713..b10ba1de27 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1236,3 +1236,42 @@ async def test_finalize_comment_sanitization( # Newline must be replaced to prevent breaking out of comment assert "\n" not in comment_line assert "INJECTED_CODE" in comment_line # still visible but safe in comment + + +@pytest.mark.asyncio +async def test_new_sub_entity_creates_and_sets( + setup_test_environment: list[str], +) -> None: + """A configured key, even with an empty config, creates the entity and passes it to the setter.""" + sub_config: dict[str, Any] = {} + created = MockObj("child") + calls: list[tuple[Any, ...]] = [] + + async def new_entity(conf: dict[str, Any], *args: Any, **kwargs: Any) -> MockObj: + calls.append((conf, args, kwargs)) + return created + + parent = MockObj("parent", "->") + result = await entity_helpers.new_sub_entity( + new_entity, {"child": sub_config}, "child", parent.set_child, 1, step=2 + ) + + assert result is created + assert calls == [(sub_config, (1,), {"step": 2})] + assert setup_test_environment == ["parent->set_child(child)"] + + +@pytest.mark.asyncio +async def test_new_sub_entity_missing_key(setup_test_environment: list[str]) -> None: + """A missing key creates nothing and emits no code.""" + + async def new_entity(conf: dict[str, Any], *args: Any) -> MockObj: + raise AssertionError("must not be called") + + parent = MockObj("parent", "->") + result = await entity_helpers.new_sub_entity( + new_entity, {}, "child", parent.set_child + ) + + assert result is None + assert setup_test_environment == []