From 7cda9a2666aef81c5b1b813c88236c07673da300 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 19:19:10 +0200 Subject: [PATCH] [core][sensor][binary_sensor][text_sensor][button][switch][number][select][debug][xiaomi_rtcgq02lm][tfluna][ld2412] Bind sub entity config and parent once with sub_* helpers (#19754) --- AGENTS.md | 19 +++-- esphome/components/binary_sensor/__init__.py | 20 +++--- esphome/components/button/__init__.py | 10 ++- esphome/components/debug/sensor.py | 25 +++---- esphome/components/debug/text_sensor.py | 11 ++- esphome/components/ld2412/binary_sensor.py | 30 +++----- esphome/components/ld2412/button/__init__.py | 28 +++----- esphome/components/ld2412/number/__init__.py | 71 ++++++++++--------- esphome/components/ld2412/select/__init__.py | 64 ++++++++--------- esphome/components/ld2412/sensor.py | 31 +++----- esphome/components/ld2412/switch/__init__.py | 13 ++-- esphome/components/ld2412/text_sensor.py | 11 ++- esphome/components/number/__init__.py | 13 +++- esphome/components/select/__init__.py | 11 ++- esphome/components/sensor/__init__.py | 20 +++--- esphome/components/switch/__init__.py | 13 +++- esphome/components/text_sensor/__init__.py | 21 +++--- esphome/components/tfluna/button/__init__.py | 13 ++-- esphome/components/tfluna/sensor.py | 19 ++--- esphome/components/tfluna/text_sensor.py | 7 +- .../xiaomi_rtcgq02lm/binary_sensor.py | 17 ++--- esphome/components/xiaomi_rtcgq02lm/sensor.py | 5 +- esphome/core/entity_helpers.py | 39 ++++++---- tests/unit_tests/core/test_entity_helpers.py | 57 ++++++++++----- 24 files changed, 281 insertions(+), 287 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 670cf55b03..6a60fc47b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,14 +322,23 @@ 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. + - **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or + `sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, + `sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented`, + then make one call per key, even when there is only one. A call creates the entity only when its key + is configured, passes it to the setter and returns it (or `None`); extra arguments such as + `min_value` or `options` go on the call. Always name the setter explicitly on the object that owns + it, never with `getattr` and an f-string, and keep that variable short (`var` for the component + itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose + setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are. ```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) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + buttons = button.sub_buttons(config, parent=var) + await buttons(CONF_RESTART, var.set_restart_button) ``` * **Automations (Triggers, Actions, Conditions):** diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index fb2ade82ce..f5cde2c8e6 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -1,4 +1,3 @@ -from collections.abc import Callable from logging import getLogger from esphome import automation, core @@ -62,16 +61,16 @@ from esphome.const import ( DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, - new_sub_entity, queue_entity_register, setup_device_class, setup_entity, ) from esphome.cpp_generator import MockObj, MockObjClass -from esphome.types import ConfigType, Expression, SafeExpType +from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -635,14 +634,11 @@ 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) +def sub_binary_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_binary_sensor.""" + return SubEntities(new_binary_sensor, config, parent) BINARY_SENSOR_CONDITION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index bdb438a99d..342f451509 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -16,8 +16,9 @@ from esphome.const import ( DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, @@ -114,6 +115,13 @@ async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: return var +def sub_buttons( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_button.""" + return SubEntities(new_button, config, parent) + + BUTTON_PRESS_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Button), diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 35eba48d13..07c67a2b93 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -106,20 +106,13 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - 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 - ) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREE, hub.set_free_sensor) + await sensors(CONF_BLOCK, hub.set_block_sensor) + await sensors(CONF_FRAGMENTATION, hub.set_fragmentation_sensor) + await sensors(CONF_MIN_FREE, hub.set_min_free_sensor) + await sensors(CONF_LOOP_TIME, hub.set_loop_time_sensor) + await sensors(CONF_PSRAM, hub.set_psram_sensor) + await sensors(CONF_CPU_FREQUENCY, hub.set_cpu_frequency_sensor) diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index cac7da0f23..43ed0d15c6 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -31,11 +31,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - 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 - ) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_DEVICE, hub.set_device_info_sensor) + await text_sensors(CONF_RESET_REASON, hub.set_reset_reason_sensor) diff --git a/esphome/components/ld2412/binary_sensor.py b/esphome/components/ld2412/binary_sensor.py index 80cff014c0..9e93727549 100644 --- a/esphome/components/ld2412/binary_sensor.py +++ b/esphome/components/ld2412/binary_sensor.py @@ -50,24 +50,12 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if dynamic_background_correction_status_config := config.get( - CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS - ): - sens = await binary_sensor.new_binary_sensor( - dynamic_background_correction_status_config - ) - cg.add( - LD2412_component.set_dynamic_background_correction_status_binary_sensor( - sens - ) - ) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(LD2412_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(LD2412_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(LD2412_component.set_still_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors( + CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS, + hub.set_dynamic_background_correction_status_binary_sensor, + ) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index 5a1ea2e6a5..9f5c868310 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -56,22 +56,12 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_factory_reset_button(b)) - if query_params_config := config.get(CONF_QUERY_PARAMS): - b = await button.new_button(query_params_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_query_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_restart_button(b)) - if start_dynamic_background_correction_config := config.get( - CONF_START_DYNAMIC_BACKGROUND_CORRECTION - ): - b = await button.new_button(start_dynamic_background_correction_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_start_dynamic_background_correction_button(b)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_QUERY_PARAMS, hub.set_query_button) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons( + CONF_START_DYNAMIC_BACKGROUND_CORRECTION, + hub.set_start_dynamic_background_correction_button, + ) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index f27e241491..0c0c01c69c 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -87,42 +87,45 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): - n = await number.new_number( - light_threshold_config, min_value=0, max_value=255, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_threshold_number(n)) - if max_distance_gate_config := config.get(CONF_MAX_DISTANCE_GATE): - n = await number.new_number( - max_distance_gate_config, min_value=2, max_value=13, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_max_distance_gate_number(n)) - if min_distance_gate_config := config.get(CONF_MIN_DISTANCE_GATE): - n = await number.new_number( - min_distance_gate_config, min_value=1, max_value=12, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_min_distance_gate_number(n)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_LIGHT_THRESHOLD, + hub.set_light_threshold_number, + min_value=0, + max_value=255, + step=1, + ) + await numbers( + CONF_MAX_DISTANCE_GATE, + hub.set_max_distance_gate_number, + min_value=2, + max_value=13, + step=1, + ) + await numbers( + CONF_MIN_DISTANCE_GATE, + hub.set_min_distance_gate_number, + min_value=1, + max_value=12, + step=1, + ) for x in range(14): if gate_conf := config.get(f"gate_{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] - n = cg.new_Pvariable(move_config[CONF_ID]) - await number.register_number( - n, move_config, min_value=0, max_value=100, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) + n = await number.new_number(move_config, min_value=0, max_value=100, step=1) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID]) - await number.register_number( - n, still_config, min_value=0, max_value=100, step=1 + n = await number.new_number( + still_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_still_threshold_number(x, n)) - if timeout_config := config.get(CONF_TIMEOUT): - n = await number.new_number(timeout_config, min_value=0, max_value=900, step=1) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_timeout_number(n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_number(x, n)) + await numbers( + CONF_TIMEOUT, + hub.set_timeout_number, + min_value=0, + max_value=900, + step=1, + ) diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index 02ecf2c30f..40a075e5d5 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -50,36 +50,34 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_baud_rate_select(s)) - if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): - s = await select.new_select( - distance_resolution_config, options=["0.2m", "0.5m", "0.75m"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_distance_resolution_select(s)) - if light_function_config := config.get(CONF_LIGHT_FUNCTION): - s = await select.new_select( - light_function_config, options=["off", "below", "above"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_function_select(s)) - if out_pin_level_config := config.get(CONF_OUT_PIN_LEVEL): - s = await select.new_select(out_pin_level_config, options=["low", "high"]) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_out_pin_level_select(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await selects( + CONF_DISTANCE_RESOLUTION, + hub.set_distance_resolution_select, + options=["0.2m", "0.5m", "0.75m"], + ) + await selects( + CONF_LIGHT_FUNCTION, + hub.set_light_function_select, + options=["off", "below", "above"], + ) + await selects( + CONF_OUT_PIN_LEVEL, + hub.set_out_pin_level_select, + options=["low", "high"], + ) diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index 0b6e676931..e236a7d5f3 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -158,30 +158,19 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): - sens = await sensor.new_sensor(detection_distance_config) - cg.add(LD2412_component.set_detection_distance_sensor(sens)) - if light_config := config.get(CONF_LIGHT): - sens = await sensor.new_sensor(light_config) - cg.add(LD2412_component.set_light_sensor(sens)) - if moving_distance_config := config.get(CONF_MOVING_DISTANCE): - sens = await sensor.new_sensor(moving_distance_config) - cg.add(LD2412_component.set_moving_target_distance_sensor(sens)) - if moving_energy_config := config.get(CONF_MOVING_ENERGY): - sens = await sensor.new_sensor(moving_energy_config) - cg.add(LD2412_component.set_moving_target_energy_sensor(sens)) - if still_distance_config := config.get(CONF_STILL_DISTANCE): - sens = await sensor.new_sensor(still_distance_config) - cg.add(LD2412_component.set_still_target_distance_sensor(sens)) - if still_energy_config := config.get(CONF_STILL_ENERGY): - sens = await sensor.new_sensor(still_energy_config) - cg.add(LD2412_component.set_still_target_energy_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DETECTION_DISTANCE, hub.set_detection_distance_sensor) + await sensors(CONF_LIGHT, hub.set_light_sensor) + await sensors(CONF_MOVING_DISTANCE, hub.set_moving_target_distance_sensor) + await sensors(CONF_MOVING_ENERGY, hub.set_moving_target_energy_sensor) + await sensors(CONF_STILL_DISTANCE, hub.set_still_target_distance_sensor) + await sensors(CONF_STILL_ENERGY, hub.set_still_target_energy_sensor) for x in range(14): if gate_conf := config.get(f"gate_{x}"): if move_config := gate_conf.get(CONF_MOVE_ENERGY): sens = await sensor.new_sensor(move_config) - cg.add(LD2412_component.set_gate_move_sensor(x, sens)) + cg.add(hub.set_gate_move_sensor(x, sens)) if still_config := gate_conf.get(CONF_STILL_ENERGY): sens = await sensor.new_sensor(still_config) - cg.add(LD2412_component.set_gate_still_sensor(x, sens)) + cg.add(hub.set_gate_still_sensor(x, sens)) diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch/__init__.py index e7f71222fd..d064305597 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_bluetooth_switch(s)) - if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): - s = await switch.new_switch(engineering_mode_config) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_engineering_mode_switch(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) + await switches(CONF_ENGINEERING_MODE, hub.set_engineering_mode_switch) diff --git a/esphome/components/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py index c8e9f42ef3..63856d9f00 100644 --- a/esphome/components/ld2412/text_sensor.py +++ b/esphome/components/ld2412/text_sensor.py @@ -28,10 +28,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(LD2412_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(LD2412_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index afa2fac483..8edbdecee5 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -79,16 +79,18 @@ from esphome.const import ( DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ @@ -325,6 +327,13 @@ async def new_number(config, *args, min_value: float, max_value: float, step: fl return var +def sub_numbers( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_number.""" + return SubEntities(new_number, config, parent) + + NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All( { cv.Required(CONF_ID): cv.use_id(Number), diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 6b7461bfa8..ae78e729e6 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -20,12 +20,14 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass, TemplateArguments +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArguments from esphome.cpp_types import global_ns +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -123,6 +125,13 @@ async def new_select(config, *args, options: list[str]): return var +def sub_selects( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_select.""" + return SubEntities(new_select, config, parent) + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(select_ns.using) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5df4a9acbd..f3293d4ed5 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1,4 +1,3 @@ -from collections.abc import Callable import logging import math @@ -112,11 +111,11 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, - new_sub_entity, queue_entity_register, setup_device_class, setup_entity, @@ -124,7 +123,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.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -1016,14 +1015,11 @@ 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) +def sub_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_sensor.""" + return SubEntities(new_sensor, config, parent) SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All( diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index d6cd061e04..c34a73a36d 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -20,14 +20,16 @@ from esphome.const import ( DEVICE_CLASS_OUTLET, DEVICE_CLASS_SWITCH, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, 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 CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -173,6 +175,13 @@ async def new_switch(config, *args): return var +def sub_switches( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_switch.""" + return SubEntities(new_switch, config, parent) + + SWITCH_ACTION_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Switch), diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 392fe325d5..08870db64a 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -1,5 +1,3 @@ -from collections.abc import Callable - from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server @@ -22,16 +20,16 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_TIMESTAMP, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, - new_sub_entity, queue_entity_register, setup_device_class, setup_entity, ) from esphome.cpp_generator import MockObj, MockObjClass -from esphome.types import ConfigType, Expression, SafeExpType +from esphome.types import ConfigType from esphome.util import Registry DEVICE_CLASSES = [ @@ -235,14 +233,11 @@ 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) +def sub_text_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_text_sensor.""" + return SubEntities(new_text_sensor, config, parent) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/tfluna/button/__init__.py b/esphome/components/tfluna/button/__init__.py index deb2ba8ffb..753ac7de33 100644 --- a/esphome/components/tfluna/button/__init__.py +++ b/esphome/components/tfluna/button/__init__.py @@ -61,12 +61,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_TFLUNA_ID]) - cg.add(tfluna_component.set_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_TFLUNA_ID]) - cg.add(tfluna_component.set_restart_button(b)) + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) diff --git a/esphome/components/tfluna/sensor.py b/esphome/components/tfluna/sensor.py index d101d12ae0..3fb9ed20a6 100644 --- a/esphome/components/tfluna/sensor.py +++ b/esphome/components/tfluna/sensor.py @@ -60,16 +60,9 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) - if distance_config := config.get(CONF_DISTANCE): - sens = await sensor.new_sensor(distance_config) - cg.add(tfluna_component.set_distance_sensor(sens)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(tfluna_component.set_temperature_sensor(sens)) - if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(signal_strength_config) - cg.add(tfluna_component.set_signal_strength_sensor(sens)) - if timestamp_config := config.get(CONF_TIMESTAMP): - sens = await sensor.new_sensor(timestamp_config) - cg.add(tfluna_component.set_timestamp_sensor(sens)) + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_SIGNAL_STRENGTH, hub.set_signal_strength_sensor) + await sensors(CONF_TIMESTAMP, hub.set_timestamp_sensor) diff --git a/esphome/components/tfluna/text_sensor.py b/esphome/components/tfluna/text_sensor.py index 6bce14252b..6844f3a726 100644 --- a/esphome/components/tfluna/text_sensor.py +++ b/esphome/components/tfluna/text_sensor.py @@ -17,7 +17,6 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(tfluna_component.set_version_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) diff --git a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py index f1b01cfd61..c122b1ce98 100644 --- a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py @@ -47,14 +47,11 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - 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])) - 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])) + binary_sensors = binary_sensor.sub_binary_sensors(config) + if await binary_sensors(CONF_MOTION, hub.set_motion): + cg.add(hub.set_motion_timeout(config[CONF_MOTION][CONF_TIMEOUT])) + await binary_sensors(CONF_LIGHT, hub.set_light) + if await binary_sensors(CONF_BUTTON, hub.set_button): + cg.add(hub.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 587b1b594e..5e849b26bc 100644 --- a/esphome/components/xiaomi_rtcgq02lm/sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/sensor.py @@ -31,6 +31,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - await sensor.new_sub_sensor(config, CONF_BATTERY_LEVEL, parent.set_battery_level) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_LEVEL, hub.set_battery_level) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 2956ac6001..4f44605025 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -468,23 +468,32 @@ 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. +@dataclass(frozen=True, slots=True) +class SubEntities: + """Create the child entity configured under key, set parent (if bound) and pass it to setter. - Returns None only when key is not configured, so the result can be used directly as a condition. + Extra arguments go to new_entity. Returns None only when key is absent, 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 + + new_entity: Callable[..., Awaitable[MockObj]] + config: ConfigType + parent: MockObj | ID | None = None + + async def __call__( + self, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, + **kwargs: Any, + ) -> MockObj | None: + if (conf := self.config.get(key)) is None: + return None + var = await self.new_entity(conf, *args, **kwargs) + if self.parent is not None: + await cg.register_parented(var, self.parent) + add(setter(var)) + return var def inherit_property_from(property_to_inherit, parent_id_property, transform=None): diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index b10ba1de27..cd6b331517 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1239,39 +1239,58 @@ async def test_finalize_comment_sanitization( @pytest.mark.asyncio -async def test_new_sub_entity_creates_and_sets( - setup_test_environment: list[str], +async def test_sub_entities_creates_and_sets( + monkeypatch: pytest.MonkeyPatch, 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") + """A configured key creates the entity, sets the parent before the setter and returns it.""" calls: list[tuple[Any, ...]] = [] + created = MockObj("child") 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 - ) + async def fake_register_parented(var: MockObj, parent: MockObj) -> None: + setup_test_environment.append(f"parent {var} {parent}") - assert result is created - assert calls == [(sub_config, (1,), {"step": 2})] - assert setup_test_environment == ["parent->set_child(child)"] + monkeypatch.setattr(entity_helpers.cg, "register_parented", fake_register_parented) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {"child": {}}, parent=hub) + + assert await sub("child", hub.set_child, 1, step=2) is created + assert calls == [({}, (1,), {"step": 2})] + assert setup_test_environment == ["parent child hub", "hub->set_child(child)"] @pytest.mark.asyncio -async def test_new_sub_entity_missing_key(setup_test_environment: list[str]) -> None: +async def test_sub_entities_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: + async def new_entity(conf: dict[str, Any]) -> MockObj: raise AssertionError("must not be called") - parent = MockObj("parent", "->") - result = await entity_helpers.new_sub_entity( - new_entity, {}, "child", parent.set_child - ) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {}) - assert result is None + assert await sub("child", hub.set_child) is None assert setup_test_environment == [] + + +@pytest.mark.asyncio +async def test_sub_entities_without_parent( + monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str] +) -> None: + """Without a bound parent, register_parented is not called.""" + + async def fail_register_parented(var: MockObj, parent: MockObj) -> None: + raise AssertionError("must not be called") + + async def new_entity(conf: dict[str, Any]) -> MockObj: + return MockObj("child") + + monkeypatch.setattr(entity_helpers.cg, "register_parented", fail_register_parented) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {"child": {CONF_ID: "child"}}) + + assert await sub("child", hub.set_child) is not None + assert setup_test_environment == ["hub->set_child(child)"]