diff --git a/esphome/__main__.py b/esphome/__main__.py index d31d3e9399..4b380f1335 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_BROKER, CONF_DEASSERT_RTS_DTR, CONF_DISABLED, + CONF_DISCOVER_IP, CONF_ESPHOME, CONF_LEVEL, CONF_LOG_TOPIC, @@ -486,8 +487,6 @@ def has_web_server_ota() -> bool: def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" - from esphome.components.mqtt import CONF_DISCOVER_IP - if CONF_MQTT not in CORE.config: return False # Default Enabled diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 35d496adeb..713969ab88 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -21,6 +21,7 @@ from esphome.const import ( CONF_CLIENT_ID, CONF_COMMAND_RETAIN, CONF_COMMAND_TOPIC, + CONF_DISCOVER_IP, CONF_DISCOVERY, CONF_DISCOVERY_OBJECT_ID_GENERATOR, CONF_DISCOVERY_PREFIX, @@ -74,7 +75,6 @@ def AUTO_LOAD(): return ["json"] -CONF_DISCOVER_IP = "discover_ip" CONF_IDF_SEND_ASYNC = "idf_send_async" CONF_WAIT_FOR_CONNECTION = "wait_for_connection" diff --git a/esphome/const.py b/esphome/const.py index 167176cf03..636cc39943 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -347,6 +347,7 @@ CONF_DISABLE_CRC = "disable_crc" CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" +CONF_DISCOVER_IP = "discover_ip" CONF_DISCOVERY = "discovery" CONF_DISCOVERY_OBJECT_ID_GENERATOR = "discovery_object_id_generator" CONF_DISCOVERY_PREFIX = "discovery_prefix" diff --git a/script/ci-custom.py b/script/ci-custom.py index 9f3d836f65..2d2da20995 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 = 1016 +CONST_PY_MAX_CONF = 1017 @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 27e102a1f9..69202b1a72 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -158,3 +158,37 @@ def test_espidf_toolchain_does_not_import_heavy_modules() -> None: "The upload fast path skips validation; importing the validation " "stack anyway defeats the validated-config cache." ) + + +def test_has_mqtt_ip_lookup_does_not_import_mqtt() -> None: + """``has_mqtt_ip_lookup`` runs on the upload/logs fast path for mqtt + configs; reading ``CONF_DISCOVER_IP`` must not drag in the mqtt + component and, with it, the validation stack. + + Runs in a subprocess because this session's other tests import the + mqtt component; the fast path itself must not. + """ + check = ( + "import sys; from esphome.__main__ import has_mqtt_ip_lookup; " + "from esphome.core import CORE; from esphome.const import CONF_MQTT; " + "CORE.config = {CONF_MQTT: {}}; " + "assert has_mqtt_ip_lookup() is True, 'mqtt IP lookup default broke'; " + f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; " + "leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; " + "print(','.join(leaked))" + ) + # check=False keeps the child's stderr (its assertion message or an + # import traceback) visible on failure. + result = subprocess.run( + [sys.executable, "-c", check], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + leaked = result.stdout.strip() + assert not leaked, ( + f"has_mqtt_ip_lookup pulls in heavy modules: {leaked}. " + "The upload/logs fast path skips validation; importing the " + "validation stack anyway defeats the validated-config cache." + ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 9bd09eed32..09c8d25249 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -3257,6 +3257,14 @@ def test_get_port_type() -> None: assert get_port_type("BOOTSEL") == "BOOTSEL" +def test_mqtt_reexports_discover_ip() -> None: + """The old import path must keep working for external code.""" + from esphome.components import mqtt + from esphome.const import CONF_DISCOVER_IP + + assert mqtt.CONF_DISCOVER_IP is CONF_DISCOVER_IP + + def test_has_mqtt_ip_lookup() -> None: """Test has_mqtt_ip_lookup function."""