MQTT: Validate entity category for read-only platforms (#182382)

This commit is contained in:
Matthias Alphart
2026-10-01 16:39:18 +02:00
committed by GitHub
parent c275c5df2c
commit e413b6b10b
5 changed files with 191 additions and 7 deletions
@@ -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)
+6 -3
View File
@@ -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:
+22 -1
View File
@@ -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)
@@ -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
+79 -1
View File
@@ -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