diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 693eacb7be1..4837a9d4f5f 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -4,7 +4,6 @@ from typing import Any from esphome import automation import esphome.codegen as cg -from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_B_CONSTANT from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv @@ -50,7 +49,6 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_USE_FAHRENHEIT, CONF_VALUE, - CONF_WEB_SERVER, CONF_WINDOW_SIZE, DEVICE_CLASS_ABSOLUTE_HUMIDITY, DEVICE_CLASS_APPARENT_POWER, @@ -116,11 +114,20 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import ( + CORE, + ID, + CoroPriority, + Lambda, + coroutine_with_priority, + entity_helpers, +) from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( SubEntities, entity_duplicate_validator, + lazy_load_validator, + mqtt_component_class, queue_entity_register, setup_device_class, setup_entity, @@ -331,12 +338,14 @@ validate_icon = cv.icon validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") _SENSOR_SCHEMA = ( - cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) + cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMPONENT_SCHEMA) - .extend(zigbee.SENSOR_SCHEMA) + .extend(entity_helpers.ZIGBEE_SENSOR_SCHEMA) .extend( { - cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent), + cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id( + mqtt_component_class("MQTTSensorComponent") + ), cv.GenerateID(): cv.declare_id(Sensor), cv.Optional( CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED @@ -378,7 +387,7 @@ _SENSOR_SCHEMA = ( ) _SENSOR_SCHEMA.add_extra(entity_duplicate_validator("sensor")) -_SENSOR_SCHEMA.add_extra(zigbee.validate_sensor) +_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_sensor")) def sensor_schema( @@ -1063,22 +1072,20 @@ async def setup_sensor_core_(var, config): CORE.add_job(_build_sensor_automations, var, config) - if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: - mqtt_ = cg.new_Pvariable(mqtt_id, var) - await mqtt.register_mqtt_component(mqtt_, config) + mqtt_ = await entity_helpers.setup_entity_integrations(var, config) + if mqtt_ is not None and ( + (expire_after := config.get(CONF_EXPIRE_AFTER, cv.UNDEFINED)) + is not cv.UNDEFINED + ): + if expire_after is None: + cg.add(mqtt_.disable_expire_after()) + else: + cg.add(mqtt_.set_expire_after(expire_after)) - if ( - expire_after := config.get(CONF_EXPIRE_AFTER, cv.UNDEFINED) - ) is not cv.UNDEFINED: - if expire_after is None: - cg.add(mqtt_.disable_expire_after()) - else: - cg.add(mqtt_.set_expire_after(expire_after)) + if "zigbee" in CORE.loaded_integrations: + from esphome.components import zigbee - if web_server_config := config.get(CONF_WEB_SERVER): - await web_server.add_entity_config(var, web_server_config) - - await zigbee.setup_sensor(var, config) + await zigbee.setup_sensor(var, config) async def register_sensor(var, config): diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 2811bd6c752..be323d1b2f6 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -65,7 +65,7 @@ from .zigbee_esp32 import ( validate_switch_esp32, zigbee_require_vfs_select, ) -from .zigbee_zephyr import zephyr_number, zephyr_sensor, zephyr_switch +from .zigbee_zephyr import zephyr_number, zephyr_switch _LOGGER = logging.getLogger(__name__) @@ -79,11 +79,7 @@ CONFLICTS_WITH = ["openthread"] # them without importing this package; re-exported here for existing consumers. BASE_SCHEMA = entity_helpers.ZIGBEE_BASE_ENTITY_SCHEMA BINARY_SENSOR_SCHEMA = entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA -SENSOR_SCHEMA = ( - cv.Schema({}) - .extend(entity_helpers.zigbee_base_entity_schema(["generic", "device_class"])) - .extend(zephyr_sensor) -) +SENSOR_SCHEMA = entity_helpers.ZIGBEE_SENSOR_SCHEMA SWITCH_SCHEMA = ( cv.Schema({}) .extend(entity_helpers.zigbee_base_entity_schema(["generic", "on_off"])) diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 28409eeecc0..d28fac1db71 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -1,9 +1,8 @@ -# These two schema keys live in esphome.const so entity base +# These schema keys live in esphome.const so entity base # schemas can use them without importing this package. -from esphome.const import CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID # noqa: F401 # pylint: disable=unused-import +from esphome.const import CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_SENSOR # noqa: F401 # pylint: disable=unused-import CONF_MAX_EP_NUMBER_ZEPHYR = 8 -CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 666799790dd..d5e76b79e93 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -60,15 +60,6 @@ ZigbeeSensor = zigbee_ns.class_("ZigbeeSensor", cg.Component) ZigbeeSwitch = zigbee_ns.class_("ZigbeeSwitch", cg.Component) ZigbeeNumber = zigbee_ns.class_("ZigbeeNumber", cg.Component) -zephyr_sensor = cv.Schema( - { - cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent), - cv.OnlyWith(CONF_ZIGBEE_SENSOR, ["nrf52", "zigbee"]): cv.declare_id( - ZigbeeSensor - ), - } -) - zephyr_switch = cv.Schema( { cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent), diff --git a/esphome/const.py b/esphome/const.py index e28b9402de2..bf36f9ee3e7 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1170,6 +1170,7 @@ CONF_YEAR = "year" CONF_ZERO = "zero" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_ID = "zigbee_id" +CONF_ZIGBEE_SENSOR = "zigbee_sensor" TYPE_GIT = "git" TYPE_LOCAL = "local" diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 0b15444b5a9..e382bfd223e 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -31,6 +31,7 @@ from esphome.const import ( CONF_WEB_SERVER_ID, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, + CONF_ZIGBEE_SENSOR, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import ( @@ -937,6 +938,7 @@ _ZIGBEE_ENTITY_CLASSES = { "ZigbeeBinarySensor", ["generic", "device_class"], ), + "sensor": (CONF_ZIGBEE_SENSOR, "ZigbeeSensor", ["generic", "device_class"]), } @@ -956,6 +958,8 @@ def _zigbee_entity_schema(platform: str) -> cv.Schema: ZIGBEE_BINARY_SENSOR_SCHEMA = _zigbee_entity_schema("binary_sensor") +ZIGBEE_SENSOR_SCHEMA = _zigbee_entity_schema("sensor") + def lazy_load_validator( component: str, name: str diff --git a/script/ci-custom.py b/script/ci-custom.py index 4aa81d287a7..3f51471378e 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -792,7 +792,7 @@ def lint_unit_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1024 +CONST_PY_MAX_CONF = 1025 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 6b63712f2a4..10a61136e05 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2788,8 +2788,8 @@ def test_should_run_benchmarks_core_benchmark_file_change() -> None: def test_python_import_benchmarked_components_imports_real_components() -> None: """The set is what importing the real benchmark's components loads.""" loaded = determine_jobs.python_import_benchmarked_components() - # mqtt is not listed in COMPONENTS; sensor imports it at module scope - assert {"binary_sensor", "sensor", "logger", "mqtt"} <= loaded + # What else they pull in changes as imports are deferred, so only the listed ones are pinned + assert {"binary_sensor", "sensor", "logger"} <= loaded def test_should_run_benchmarks_python_import_benchmarked_component() -> None: diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 5e70522a01b..ba09e13bc3f 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -19,6 +19,8 @@ from pathlib import Path import subprocess import sys +import pytest + # Modules that must only load for the commands that actually use them # (compile/config validation, shell completion), never from a bare # ``import esphome.__main__``. @@ -218,21 +220,22 @@ def test_api_client_does_not_import_heavy_modules() -> None: ) -def test_binary_sensor_does_not_import_integrations() -> None: +@pytest.mark.parametrize("component", ["binary_sensor", "sensor"]) +def test_entity_component_does_not_import_integrations(component: str) -> None: """An entity component must not drag in its optional integrations. - binary_sensor's mqtt/web_server/zigbee schema fragments live in + The mqtt/web_server/zigbee schema fragments live in ``esphome.core.entity_helpers``; the integration packages (and the esp32/logger chains mqtt and zigbee pull in) must only load when the user's config actually uses them. """ allowed = ( "esphome.components", - "esphome.components.binary_sensor", + f"esphome.components.{component}", "esphome.components.const", ) check = ( - "import sys; import esphome.components.binary_sensor; " + f"import sys; import esphome.components.{component}; " f"leaked = [m for m in sys.modules " f"if m.startswith('esphome.components') and m not in {allowed!r}]; " "print(','.join(leaked))" @@ -245,7 +248,7 @@ def test_binary_sensor_does_not_import_integrations() -> None: ) leaked = result.stdout.strip() assert not leaked, ( - f"esphome.components.binary_sensor imports integration packages at " + f"esphome.components.{component} imports integration packages at " f"top level: {leaked}. Keep the shared schema fragments in " "esphome.core.entity_helpers and import the integration inside " "to_code instead."