From e413b6b10b93b06bf31a6cc3c53021aba12fe1da Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 1 Oct 2026 16:39:18 +0200 Subject: [PATCH] MQTT: Validate entity category for read-only platforms (#182382) --- .../components/mqtt/binary_sensor.py | 8 +- homeassistant/components/mqtt/sensor.py | 9 ++- homeassistant/components/mqtt/util.py | 23 +++++- tests/components/mqtt/test_binary_sensor.py | 78 ++++++++++++++++++ tests/components/mqtt/test_sensor.py | 80 ++++++++++++++++++- 5 files changed, 191 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 9de17c29beff..243839e988c1 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, + CONF_ENTITY_CATEGORY, CONF_FORCE_UPDATE, CONF_NAME, CONF_PAYLOAD_OFF, @@ -37,6 +38,7 @@ from .const import CONF_OFF_DELAY, CONF_STATE_TOPIC, PAYLOAD_NONE from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA +from .util import entity_category_without_config _LOGGER = logging.getLogger(__name__) @@ -48,7 +50,7 @@ DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False CONF_EXPIRE_AFTER = "expire_after" -PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend(MQTT_ENTITY_COMMON_SCHEMA.schema).extend( { probatio.Optional(CONF_DEVICE_CLASS): probatio.Any(DEVICE_CLASSES_SCHEMA, None), probatio.Optional(CONF_EXPIRE_AFTER): cv.positive_int, @@ -57,8 +59,10 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( probatio.Optional(CONF_OFF_DELAY): cv.positive_int, probatio.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, probatio.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + # a binary sensor can not be added as a config entity + probatio.Optional(CONF_ENTITY_CATEGORY): entity_category_without_config, } -).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) +) DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=probatio.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 9346fb996724..306f8a036ded 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -26,6 +26,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, + CONF_ENTITY_CATEGORY, CONF_FORCE_UPDATE, CONF_NAME, CONF_OPTIONS, @@ -54,7 +55,7 @@ from .const import ( from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA -from .util import check_state_too_long +from .util import check_state_too_long, entity_category_without_config _LOGGER = logging.getLogger(__name__) @@ -74,7 +75,7 @@ URL_DOCS_SUPPORTED_SENSOR_UOM = ( "https://www.home-assistant.io/integrations/sensor/#device-class" ) -_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend(MQTT_ENTITY_COMMON_SCHEMA.schema).extend( { probatio.Optional(CONF_DEVICE_CLASS): probatio.Any(DEVICE_CLASSES_SCHEMA, None), probatio.Optional(CONF_EXPIRE_AFTER): cv.positive_int, @@ -85,8 +86,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( probatio.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int, probatio.Optional(CONF_STATE_CLASS): probatio.Any(STATE_CLASSES_SCHEMA, None), probatio.Optional(CONF_UNIT_OF_MEASUREMENT): probatio.Any(cv.string, None), + # a sensor can not be added as a config entity + probatio.Optional(CONF_ENTITY_CATEGORY): entity_category_without_config, } -).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) +) def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigType: diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index d7fc1c7b60bd..64733329e0f4 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -12,7 +12,12 @@ from typing import Any import probatio from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import MAX_LENGTH_STATE_STATE, STATE_UNKNOWN, Platform +from homeassistant.const import ( + MAX_LENGTH_STATE_STATE, + STATE_UNKNOWN, + EntityCategory, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( @@ -21,6 +26,7 @@ from homeassistant.helpers import ( entity_registry as er, template, ) +from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -326,6 +332,21 @@ def valid_publish_topic(topic: Any) -> str: return validated_topic +def entity_category_without_config(value: Any) -> EntityCategory: + """Validate the entity category of a platform not supporting `CONFIG`. + + Entities of these platforms raise when they are added as config entities. + """ + entity_category: EntityCategory = ENTITY_CATEGORIES_SCHEMA(value) + if entity_category is EntityCategory.CONFIG: + _options = ", ".join(sorted(set(EntityCategory) - {EntityCategory.CONFIG})) + raise probatio.Invalid( + f"Entity category '{entity_category}' is not supported by this platform." + f" Valid options are: {_options}" + ) + return entity_category + + def valid_qos_schema(qos: Any) -> int: """Validate that QOS value is valid.""" validated_qos: int = _VALID_QOS_SCHEMA(qos) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 084c680c90a6..e23538acbe75 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -1269,3 +1270,80 @@ async def test_value_template_fails( "TypeError: unsupported operand type(s) for *:" " 'NoneType' and 'int' rendering template" in caplog.text ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + DOMAIN: { + binary_sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } + } + } + ], +) +async def test_unsupported_entity_category( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test `config` entity category is rejected for this read-only platform.""" + assert await mqtt_mock_entry() + assert "Entity category 'config' is not supported by this platform" in caplog.text + assert hass.states.get("binary_sensor.test") is None + + +async def test_unsupported_entity_category_discovery( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a discovered `config` entity category is rejected.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{"name": "test", "state_topic": "test-topic",' + ' "unique_id": "veryunique", "entity_category": "config"}', + ) + await hass.async_block_till_done() + assert "Entity category 'config' is not supported by this platform" in caplog.text + assert ( + entity_registry.async_get_entity_id(binary_sensor.DOMAIN, DOMAIN, "veryunique") + is None + ) + + +async def test_unsupported_entity_category_discovery_update( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test an unsupported entity category is rejected on a discovery update.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{"name": "test", "state_topic": "test-topic", "unique_id": "veryunique"}', + ) + await hass.async_block_till_done() + entity_id = entity_registry.async_get_entity_id( + binary_sensor.DOMAIN, DOMAIN, "veryunique" + ) + assert entity_id is not None + + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{"name": "test", "state_topic": "test-topic",' + ' "unique_id": "veryunique", "entity_category": "config"}', + ) + await hass.async_block_till_done() + assert "Entity category 'config' is not supported by this platform" in caplog.text + assert entity_registry.async_get(entity_id).entity_category is None diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 747576eaa22a..5e393ca8a0ca 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -27,7 +27,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -2033,3 +2037,77 @@ async def test_value_incorrect_state_class_config( "The option `last_reset_value_template` cannot be used" " together with state class" in caplog.text ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } + } + } + ], +) +async def test_unsupported_entity_category( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test `config` entity category is rejected for this read-only platform.""" + assert await mqtt_mock_entry() + assert "Entity category 'config' is not supported by this platform" in caplog.text + assert hass.states.get("sensor.test") is None + + +async def test_unsupported_entity_category_discovery( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a discovered `config` entity category is rejected.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/sensor/bla/config", + '{"name": "test", "state_topic": "test-topic",' + ' "unique_id": "veryunique", "entity_category": "config"}', + ) + await hass.async_block_till_done() + assert "Entity category 'config' is not supported by this platform" in caplog.text + assert ( + entity_registry.async_get_entity_id(sensor.DOMAIN, DOMAIN, "veryunique") is None + ) + + +async def test_unsupported_entity_category_discovery_update( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test an unsupported entity category is rejected on a discovery update.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/sensor/bla/config", + '{"name": "test", "state_topic": "test-topic", "unique_id": "veryunique"}', + ) + await hass.async_block_till_done() + entity_id = entity_registry.async_get_entity_id(sensor.DOMAIN, DOMAIN, "veryunique") + assert entity_id is not None + + async_fire_mqtt_message( + hass, + "homeassistant/sensor/bla/config", + '{"name": "test", "state_topic": "test-topic",' + ' "unique_id": "veryunique", "entity_category": "config"}', + ) + await hass.async_block_till_done() + assert "Entity category 'config' is not supported by this platform" in caplog.text + assert entity_registry.async_get(entity_id).entity_category is None