diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 6ad76046a1..d1f7318de2 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -3,7 +3,6 @@ import math 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 import esphome.config_validation as cv from esphome.const import ( @@ -110,10 +109,12 @@ 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, CoroPriority, coroutine_with_priority, entity_helpers from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + lazy_load_validator, + mqtt_component_class, queue_entity_register, setup_device_class, setup_entity, @@ -314,12 +315,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 @@ -359,7 +362,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( @@ -980,6 +983,8 @@ 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: + from esphome.components import mqtt + mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) @@ -992,9 +997,14 @@ async def setup_sensor_core_(var, config): cg.add(mqtt_.set_expire_after(expire_after)) if web_server_config := config.get(CONF_WEB_SERVER): + from esphome.components import web_server + await web_server.add_entity_config(var, web_server_config) - await zigbee.setup_sensor(var, config) + if "zigbee" in CORE.loaded_integrations: + from esphome.components import zigbee + + 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 9bee84a13f..20288d4585 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -47,7 +47,7 @@ from .zigbee_esp32 import ( validate_sensor_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__) @@ -60,7 +60,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(BASE_SCHEMA).extend(zephyr_sensor) +SENSOR_SCHEMA = entity_helpers.ZIGBEE_SENSOR_SCHEMA SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 34329cd6e8..bcfcf4c0a5 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -3,10 +3,10 @@ from esphome.const import ( # noqa: F401 # pylint: disable=unused-import CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, + CONF_ZIGBEE_SENSOR, ) CONF_MAX_EP_NUMBER_ZEPHYR = 8 -CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" CONF_SLEEPY = "sleepy" diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index ecbcea5d0e..7f1afd4939 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -57,15 +57,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 cd1bf949af..cef418165d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1151,6 +1151,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 40f9dbd9a3..7dc760ea85 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -27,6 +27,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 ( @@ -743,6 +744,7 @@ ZIGBEE_BASE_ENTITY_SCHEMA = cv.Schema( # class against the owning declaration in zigbee_zephyr. _ZIGBEE_ENTITY_CLASSES = { "binary_sensor": (CONF_ZIGBEE_BINARY_SENSOR, "ZigbeeBinarySensor"), + "sensor": (CONF_ZIGBEE_SENSOR, "ZigbeeSensor"), } @@ -762,6 +764,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 4fbd5ec9fc..2a325389d1 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -557,7 +557,7 @@ def lint_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/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index abcbe1ed41..7c14906d2c 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__``. @@ -190,21 +192,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))" @@ -217,7 +220,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."