diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 3bf9cf218f2..1e1011cc381 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -39,6 +39,7 @@ CONF_ENCODING = "encoding" CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" CONF_KEEPALIVE = "keepalive" +CONF_OPTIONS = "options" CONF_ORIGIN = "origin" CONF_QOS = ATTR_QOS CONF_RETAIN = ATTR_RETAIN diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 5cc7a586c71..e301892dee2 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -19,7 +19,12 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import subscription from .config import MQTT_RW_SCHEMA -from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_OPTIONS, + CONF_STATE_TOPIC, +) from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, @@ -32,8 +37,6 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA _LOGGER = logging.getLogger(__name__) -CONF_OPTIONS = "options" - DEFAULT_NAME = "MQTT Select" MQTT_SELECT_ATTRIBUTES_BLOCKED = frozenset( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index e983f1b66f3..d689b6bb767 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -38,7 +38,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_STATE_TOPIC, PAYLOAD_NONE +from .const import CONF_OPTIONS, CONF_STATE_TOPIC, PAYLOAD_NONE from .mixins import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import ( MqttValueTemplate, @@ -72,6 +72,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_OPTIONS): cv.ensure_list, vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int, vol.Optional(CONF_STATE_CLASS): vol.Any(STATE_CLASSES_SCHEMA, None), vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(cv.string, None), @@ -79,8 +80,8 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -def validate_sensor_state_class_config(config: ConfigType) -> ConfigType: - """Validate the sensor state class config.""" +def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigType: + """Validate the sensor options, state and device class config.""" if ( CONF_LAST_RESET_VALUE_TEMPLATE in config and (state_class := config.get(CONF_STATE_CLASS)) != SensorStateClass.TOTAL @@ -90,17 +91,35 @@ def validate_sensor_state_class_config(config: ConfigType) -> ConfigType: f"together with state class `{state_class}`" ) + # Only allow `options` to be set for `enum` sensors + # to limit the possible sensor values + if (options := config.get(CONF_OPTIONS)) is not None: + if not options: + raise vol.Invalid("An empty options list is not allowed") + if config.get(CONF_STATE_CLASS) or config.get(CONF_UNIT_OF_MEASUREMENT): + raise vol.Invalid( + f"Specifying `{CONF_OPTIONS}` is not allowed together with " + f"the `{CONF_STATE_CLASS}` or `{CONF_UNIT_OF_MEASUREMENT}` option" + ) + + if (device_class := config.get(CONF_DEVICE_CLASS)) != SensorDeviceClass.ENUM: + raise vol.Invalid( + f"The option `{CONF_OPTIONS}` can only be used " + f"together with device class `{SensorDeviceClass.ENUM}`, " + f"got `{CONF_DEVICE_CLASS}` '{device_class}'" + ) + return config PLATFORM_SCHEMA_MODERN = vol.All( _PLATFORM_SCHEMA_BASE, - validate_sensor_state_class_config, + validate_sensor_state_and_device_class_config, ) DISCOVERY_SCHEMA = vol.All( _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), - validate_sensor_state_class_config, + validate_sensor_state_and_device_class_config, ) @@ -197,6 +216,7 @@ class MqttSensor(MqttEntity, RestoreSensor): CONF_SUGGESTED_DISPLAY_PRECISION ) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_options = config.get(CONF_OPTIONS) self._attr_state_class = config.get(CONF_STATE_CLASS) self._expire_after = config.get(CONF_EXPIRE_AFTER) @@ -252,6 +272,15 @@ class MqttSensor(MqttEntity, RestoreSensor): else: self._attr_native_value = payload return + if self.options and payload not in self.options: + _LOGGER.warning( + "Ignoring invalid option received on topic '%s', got '%s', allowed: %s", + msg.topic, + payload, + ", ".join(self.options), + ) + return + if self.device_class in { None, SensorDeviceClass.ENUM, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 4b117aaa4d5..a62c36404ca 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -3,6 +3,7 @@ import copy from datetime import datetime, timedelta import json +import logging from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -110,6 +111,48 @@ async def test_setting_sensor_value_via_mqtt_message( assert state.attributes.get("unit_of_measurement") == "fav unit" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "enum", + "options": ["red", "green", "blue"], + } + } + }, + ], +) +async def test_setting_enum_sensor_value_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT of an enum type sensor.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", "red") + state = hass.states.get("sensor.test") + assert state.state == "red" + + async_fire_mqtt_message(hass, "test-topic", "green") + state = hass.states.get("sensor.test") + assert state.state == "green" + + with caplog.at_level(logging.WARNING): + async_fire_mqtt_message(hass, "test-topic", "yellow") + assert ( + "Ignoring invalid option received on topic 'test-topic', " + "got 'yellow', allowed: red, green, blue" in caplog.text + ) + # Assert the state update was filtered out and ignored + state = hass.states.get("sensor.test") + assert state.state == "green" + + @pytest.mark.parametrize( "hass_config", [ @@ -874,6 +917,61 @@ async def test_invalid_state_class( assert "expected SensorStateClass or one of" in caplog.text +@pytest.mark.parametrize( + ("hass_config", "error_logged"), + [ + ( + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "state_class": "measurement", + "options": ["red", "green", "blue"], + } + } + }, + "Specifying `options` is not allowed together with the `state_class` " + "or `unit_of_measurement` option", + ), + ( + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "gas", + "options": ["red", "green", "blue"], + } + } + }, + "The option `options` can only be used together with " + "device class `enum`, got `device_class` 'gas'", + ), + ( + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "options": [], + } + } + }, + "An empty options list is not allowed", + ), + ], +) +async def test_invalid_options_config( + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + error_logged: str, +) -> None: + """Test state_class, deviceclass with sensor options.""" + assert await mqtt_mock_entry() + assert error_logged in caplog.text + + @pytest.mark.parametrize( "hass_config", [ @@ -891,6 +989,13 @@ async def test_invalid_state_class( "state_topic": "test-topic", "state_class": None, }, + { + "name": "Test 4", + "state_topic": "test-topic", + "state_class": None, + "device_class": "enum", + "options": ["red", "green", "blue"], + }, ] } }