Do not fail MQTT setup if lights configured via yaml can't be validated (#101649)

* Add light

* Deduplicate code

* Follow up comment
This commit is contained in:
Jan Bouwhuis 2023-10-19 17:34:43 +02:00 committed by GitHub
parent 90687e9794
commit d149bffb07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 107 additions and 166 deletions

View file

@ -26,7 +26,6 @@ from . import (
humidifier as humidifier_platform, humidifier as humidifier_platform,
image as image_platform, image as image_platform,
lawn_mower as lawn_mower_platform, lawn_mower as lawn_mower_platform,
light as light_platform,
lock as lock_platform, lock as lock_platform,
number as number_platform, number as number_platform,
scene as scene_platform, scene as scene_platform,
@ -100,14 +99,11 @@ CONFIG_SCHEMA_BASE = vol.Schema(
cv.ensure_list, cv.ensure_list,
[lawn_mower_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] [lawn_mower_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type]
), ),
Platform.LIGHT.value: vol.All(cv.ensure_list, [dict]),
Platform.LOCK.value: vol.All( Platform.LOCK.value: vol.All(
cv.ensure_list, cv.ensure_list,
[lock_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] [lock_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type]
), ),
Platform.LIGHT.value: vol.All(
cv.ensure_list,
[light_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type]
),
Platform.NUMBER.value: vol.All( Platform.NUMBER.value: vol.All(
cv.ensure_list, cv.ensure_list,
[number_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] [number_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type]

View file

@ -1,7 +1,6 @@
"""Support for MQTT lights.""" """Support for MQTT lights."""
from __future__ import annotations from __future__ import annotations
import functools
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
@ -10,24 +9,24 @@ from homeassistant.components import light
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType
from ..mixins import async_setup_entry_helper from ..mixins import async_mqtt_entry_helper
from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA
from .schema_basic import ( from .schema_basic import (
DISCOVERY_SCHEMA_BASIC, DISCOVERY_SCHEMA_BASIC,
PLATFORM_SCHEMA_MODERN_BASIC, PLATFORM_SCHEMA_MODERN_BASIC,
async_setup_entity_basic, MqttLight,
) )
from .schema_json import ( from .schema_json import (
DISCOVERY_SCHEMA_JSON, DISCOVERY_SCHEMA_JSON,
PLATFORM_SCHEMA_MODERN_JSON, PLATFORM_SCHEMA_MODERN_JSON,
async_setup_entity_json, MqttLightJson,
) )
from .schema_template import ( from .schema_template import (
DISCOVERY_SCHEMA_TEMPLATE, DISCOVERY_SCHEMA_TEMPLATE,
PLATFORM_SCHEMA_MODERN_TEMPLATE, PLATFORM_SCHEMA_MODERN_TEMPLATE,
async_setup_entity_template, MqttLightTemplate,
) )
@ -70,25 +69,13 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up MQTT lights through YAML and through MQTT discovery.""" """Set up MQTT lights through YAML and through MQTT discovery."""
setup = functools.partial( await async_mqtt_entry_helper(
_async_setup_entity, hass, async_add_entities, config_entry=config_entry hass,
) config_entry,
await async_setup_entry_helper(hass, light.DOMAIN, setup, DISCOVERY_SCHEMA) None,
light.DOMAIN,
async_add_entities,
async def _async_setup_entity( DISCOVERY_SCHEMA,
hass: HomeAssistant, PLATFORM_SCHEMA_MODERN,
async_add_entities: AddEntitiesCallback, {"basic": MqttLight, "json": MqttLightJson, "template": MqttLightTemplate},
config: ConfigType,
config_entry: ConfigEntry,
discovery_data: DiscoveryInfoType | None = None,
) -> None:
"""Set up a MQTT Light."""
setup_entity = {
"basic": async_setup_entity_basic,
"json": async_setup_entity_json,
"template": async_setup_entity_template,
}
await setup_entity[config[CONF_SCHEMA]](
hass, config, async_add_entities, config_entry, discovery_data
) )

View file

@ -28,7 +28,6 @@ from homeassistant.components.light import (
LightEntityFeature, LightEntityFeature,
valid_supported_color_modes, valid_supported_color_modes,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_NAME, CONF_NAME,
CONF_OPTIMISTIC, CONF_OPTIMISTIC,
@ -36,11 +35,10 @@ from homeassistant.const import (
CONF_PAYLOAD_ON, CONF_PAYLOAD_ON,
STATE_ON, STATE_ON,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
from .. import subscription from .. import subscription
@ -228,17 +226,6 @@ DISCOVERY_SCHEMA_BASIC = vol.All(
) )
async def async_setup_entity_basic(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
config_entry: ConfigEntry,
discovery_data: DiscoveryInfoType | None,
) -> None:
"""Set up a MQTT Light."""
async_add_entities([MqttLight(hass, config, config_entry, discovery_data)])
class MqttLight(MqttEntity, LightEntity, RestoreEntity): class MqttLight(MqttEntity, LightEntity, RestoreEntity):
"""Representation of a MQTT light.""" """Representation of a MQTT light."""

View file

@ -32,7 +32,6 @@ from homeassistant.components.light import (
filter_supported_color_modes, filter_supported_color_modes,
valid_supported_color_modes, valid_supported_color_modes,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_BRIGHTNESS, CONF_BRIGHTNESS,
CONF_COLOR_TEMP, CONF_COLOR_TEMP,
@ -44,12 +43,11 @@ from homeassistant.const import (
CONF_XY, CONF_XY,
STATE_ON, STATE_ON,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.json import json_dumps from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
from homeassistant.util.json import json_loads_object from homeassistant.util.json import json_loads_object
@ -166,17 +164,6 @@ PLATFORM_SCHEMA_MODERN_JSON = vol.All(
) )
async def async_setup_entity_json(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
config_entry: ConfigEntry,
discovery_data: DiscoveryInfoType | None,
) -> None:
"""Set up a MQTT JSON Light."""
async_add_entities([MqttLightJson(hass, config, config_entry, discovery_data)])
class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
"""Representation of a MQTT JSON light.""" """Representation of a MQTT JSON light."""

View file

@ -20,7 +20,6 @@ from homeassistant.components.light import (
LightEntityFeature, LightEntityFeature,
filter_supported_color_modes, filter_supported_color_modes,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_NAME, CONF_NAME,
CONF_OPTIMISTIC, CONF_OPTIMISTIC,
@ -28,11 +27,10 @@ from homeassistant.const import (
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType from homeassistant.helpers.typing import ConfigType, TemplateVarsType
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
from .. import subscription from .. import subscription
@ -113,17 +111,6 @@ DISCOVERY_SCHEMA_TEMPLATE = vol.All(
) )
async def async_setup_entity_template(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
config_entry: ConfigEntry,
discovery_data: DiscoveryInfoType | None,
) -> None:
"""Set up a MQTT Template light."""
async_add_entities([MqttLightTemplate(hass, config, config_entry, discovery_data)])
class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
"""Representation of a MQTT Template light.""" """Representation of a MQTT Template light."""

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
import functools
from functools import partial, wraps from functools import partial, wraps
import logging import logging
from typing import TYPE_CHECKING, Any, Protocol, cast, final from typing import TYPE_CHECKING, Any, Protocol, cast, final
@ -81,6 +82,7 @@ from .const import (
CONF_OBJECT_ID, CONF_OBJECT_ID,
CONF_ORIGIN, CONF_ORIGIN,
CONF_QOS, CONF_QOS,
CONF_SCHEMA,
CONF_SUGGESTED_AREA, CONF_SUGGESTED_AREA,
CONF_SW_VERSION, CONF_SW_VERSION,
CONF_TOPIC, CONF_TOPIC,
@ -272,6 +274,38 @@ def async_handle_schema_error(
) )
async def _async_discover(
hass: HomeAssistant,
domain: str,
async_setup: partial[Coroutine[Any, Any, None]],
discovery_payload: MQTTDiscoveryPayload,
) -> None:
"""Discover and add an MQTT entity, automation or tag."""
if not mqtt_config_entry_enabled(hass):
_LOGGER.warning(
(
"MQTT integration is disabled, skipping setup of discovered item "
"MQTT %s, payload %s"
),
domain,
discovery_payload,
)
return
discovery_data = discovery_payload.discovery_data
try:
await async_setup(discovery_payload)
except vol.Invalid as err:
discovery_hash = discovery_data[ATTR_DISCOVERY_HASH]
clear_discovery_hash(hass, discovery_hash)
async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None)
async_handle_schema_error(discovery_payload, err)
except Exception:
discovery_hash = discovery_data[ATTR_DISCOVERY_HASH]
clear_discovery_hash(hass, discovery_hash)
async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None)
raise
async def async_setup_entry_helper( async def async_setup_entry_helper(
hass: HomeAssistant, hass: HomeAssistant,
domain: str, domain: str,
@ -281,43 +315,25 @@ async def async_setup_entry_helper(
"""Set up entity, automation or tag creation dynamically through MQTT discovery.""" """Set up entity, automation or tag creation dynamically through MQTT discovery."""
mqtt_data = get_mqtt_data(hass) mqtt_data = get_mqtt_data(hass)
async def async_discover(discovery_payload: MQTTDiscoveryPayload) -> None: async def async_setup_from_discovery(
"""Discover and add an MQTT entity, automation or tag.""" discovery_payload: MQTTDiscoveryPayload,
if not mqtt_config_entry_enabled(hass): ) -> None:
_LOGGER.warning( """Set up an MQTT entity, automation or tag from discovery."""
( config: DiscoveryInfoType = discovery_schema(discovery_payload)
"MQTT integration is disabled, skipping setup of discovered item " await async_setup(config, discovery_data=discovery_payload.discovery_data)
"MQTT %s, payload %s"
),
domain,
discovery_payload,
)
return
discovery_data = discovery_payload.discovery_data
try:
config: DiscoveryInfoType = discovery_schema(discovery_payload)
await async_setup(config, discovery_data=discovery_data)
except vol.Invalid as err:
discovery_hash = discovery_data[ATTR_DISCOVERY_HASH]
clear_discovery_hash(hass, discovery_hash)
async_dispatcher_send(
hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None
)
async_handle_schema_error(discovery_payload, err)
except Exception:
discovery_hash = discovery_data[ATTR_DISCOVERY_HASH]
clear_discovery_hash(hass, discovery_hash)
async_dispatcher_send(
hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None
)
raise
mqtt_data.reload_dispatchers.append( mqtt_data.reload_dispatchers.append(
async_dispatcher_connect( async_dispatcher_connect(
hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), async_discover hass,
MQTT_DISCOVERY_NEW.format(domain, "mqtt"),
functools.partial(
_async_discover, hass, domain, async_setup_from_discovery
),
) )
) )
# The setup of manual configured MQTT entities will be migrated to async_mqtt_entry_helper.
# The following setup code will be cleaned up after the last entity platform has been migrated.
async def _async_setup_entities() -> None: async def _async_setup_entities() -> None:
"""Set up MQTT items from configuration.yaml.""" """Set up MQTT items from configuration.yaml."""
mqtt_data = get_mqtt_data(hass) mqtt_data = get_mqtt_data(hass)
@ -342,54 +358,43 @@ async def async_setup_entry_helper(
async def async_mqtt_entry_helper( async def async_mqtt_entry_helper(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
entity_class: type[MqttEntity], entity_class: type[MqttEntity] | None,
domain: str, domain: str,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_schema: vol.Schema, discovery_schema: vol.Schema,
platform_schema_modern: vol.Schema, platform_schema_modern: vol.Schema,
schema_class_mapping: dict[str, type[MqttEntity]] | None = None,
) -> None: ) -> None:
"""Set up entity, automation or tag creation dynamically through MQTT discovery.""" """Set up entity, automation or tag creation dynamically through MQTT discovery."""
mqtt_data = get_mqtt_data(hass) mqtt_data = get_mqtt_data(hass)
async def async_discover(discovery_payload: MQTTDiscoveryPayload) -> None: async def async_setup_from_discovery(
"""Discover and add an MQTT entity, automation or tag.""" discovery_payload: MQTTDiscoveryPayload,
if not mqtt_config_entry_enabled(hass): ) -> None:
_LOGGER.warning( """Set up an MQTT entity from discovery."""
( nonlocal entity_class
"MQTT integration is disabled, skipping setup of discovered item " config: DiscoveryInfoType = discovery_schema(discovery_payload)
"MQTT %s, payload %s" if schema_class_mapping is not None:
), entity_class = schema_class_mapping[config[CONF_SCHEMA]]
domain, if TYPE_CHECKING:
discovery_payload, assert entity_class is not None
) async_add_entities(
return [entity_class(hass, config, entry, discovery_payload.discovery_data)]
discovery_data = discovery_payload.discovery_data )
try:
config: DiscoveryInfoType = discovery_schema(discovery_payload)
async_add_entities([entity_class(hass, config, entry, discovery_data)])
except vol.Invalid as err:
discovery_hash = discovery_data[ATTR_DISCOVERY_HASH]
clear_discovery_hash(hass, discovery_hash)
async_dispatcher_send(
hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None
)
async_handle_schema_error(discovery_payload, err)
except Exception:
discovery_hash = discovery_data[ATTR_DISCOVERY_HASH]
clear_discovery_hash(hass, discovery_hash)
async_dispatcher_send(
hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None
)
raise
mqtt_data.reload_dispatchers.append( mqtt_data.reload_dispatchers.append(
async_dispatcher_connect( async_dispatcher_connect(
hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), async_discover hass,
MQTT_DISCOVERY_NEW.format(domain, "mqtt"),
functools.partial(
_async_discover, hass, domain, async_setup_from_discovery
),
) )
) )
async def _async_setup_entities() -> None: async def _async_setup_entities() -> None:
"""Set up MQTT items from configuration.yaml.""" """Set up MQTT items from configuration.yaml."""
nonlocal entity_class
mqtt_data = get_mqtt_data(hass) mqtt_data = get_mqtt_data(hass)
if not (config_yaml := mqtt_data.config): if not (config_yaml := mqtt_data.config):
return return
@ -404,6 +409,10 @@ async def async_mqtt_entry_helper(
for yaml_config in yaml_configs: for yaml_config in yaml_configs:
try: try:
config = platform_schema_modern(yaml_config) config = platform_schema_modern(yaml_config)
if schema_class_mapping is not None:
entity_class = schema_class_mapping[config[CONF_SCHEMA]]
if TYPE_CHECKING:
assert entity_class is not None
entities.append(entity_class(hass, config, entry, None)) entities.append(entity_class(hass, config, entry, None))
except vol.Invalid as ex: except vol.Invalid as ex:
error = str(ex) error = str(ex)

View file

@ -2114,35 +2114,30 @@ async def test_handle_message_callback(
} }
], ],
) )
@patch("homeassistant.components.mqtt.PLATFORMS", []) @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT])
async def test_setup_manual_mqtt_with_platform_key( async def test_setup_manual_mqtt_with_platform_key(
hass: HomeAssistant, hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test set up a manual MQTT item with a platform key.""" """Test set up a manual MQTT item with a platform key."""
with pytest.raises(AssertionError): assert await mqtt_mock_entry()
await mqtt_mock_entry()
assert ( assert (
"Invalid config for [mqtt]: [platform] is an invalid option for [mqtt]" "extra keys not allowed @ data['platform'] for manual configured MQTT light item"
in caplog.text in caplog.text
) )
@pytest.mark.parametrize("hass_config", [{mqtt.DOMAIN: {"light": {"name": "test"}}}]) @pytest.mark.parametrize("hass_config", [{mqtt.DOMAIN: {"light": {"name": "test"}}}])
@patch("homeassistant.components.mqtt.PLATFORMS", []) @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT])
async def test_setup_manual_mqtt_with_invalid_config( async def test_setup_manual_mqtt_with_invalid_config(
hass: HomeAssistant, hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test set up a manual MQTT item with an invalid config.""" """Test set up a manual MQTT item with an invalid config."""
with pytest.raises(AssertionError): assert await mqtt_mock_entry()
await mqtt_mock_entry() assert "required key not provided" in caplog.text
assert (
"Invalid config for [mqtt]: required key not provided @ data['mqtt'][0]['light'][0]['command_topic']. "
"Got None. (See ?, line ?)" in caplog.text
)
@patch("homeassistant.components.mqtt.PLATFORMS", []) @patch("homeassistant.components.mqtt.PLATFORMS", [])

View file

@ -253,9 +253,8 @@ async def test_fail_setup_if_no_command_topic(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test if command fails with command topic.""" """Test if command fails with command topic."""
with pytest.raises(AssertionError): assert await mqtt_mock_entry()
await mqtt_mock_entry() assert "required key not provided" in caplog.text
assert "Invalid config for [mqtt]: required key not provided" in caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(

View file

@ -197,9 +197,8 @@ async def test_fail_setup_if_no_command_topic(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test if setup fails with no command topic.""" """Test if setup fails with no command topic."""
with pytest.raises(AssertionError): assert await mqtt_mock_entry()
await mqtt_mock_entry() assert "required key not provided" in caplog.text
assert "Invalid config for [mqtt]: required key not provided" in caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -217,12 +216,8 @@ async def test_fail_setup_if_color_mode_deprecated(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test if setup fails if color mode is combined with deprecated config keys.""" """Test if setup fails if color mode is combined with deprecated config keys."""
with pytest.raises(AssertionError): assert await mqtt_mock_entry()
await mqtt_mock_entry() assert "color_mode must not be combined with any of" in caplog.text
assert (
"Invalid config for [mqtt]: color_mode must not be combined with any of"
in caplog.text
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -250,7 +245,7 @@ async def test_fail_setup_if_color_mode_deprecated(
COLOR_MODES_CONFIG, COLOR_MODES_CONFIG,
({"supported_color_modes": ["unknown"]},), ({"supported_color_modes": ["unknown"]},),
), ),
"Invalid config for [mqtt]: value must be one of [<ColorMode.", "value must be one of [<ColorMode.",
), ),
], ],
) )
@ -261,8 +256,7 @@ async def test_fail_setup_if_color_modes_invalid(
error: str, error: str,
) -> None: ) -> None:
"""Test if setup fails if supported color modes is invalid.""" """Test if setup fails if supported color modes is invalid."""
with pytest.raises(AssertionError): assert await mqtt_mock_entry()
await mqtt_mock_entry()
assert error in caplog.text assert error in caplog.text