Allow to set options for an MQTT enum sensor (#123248)

* Add options attribute support for MQTT sensor

* Add comment
This commit is contained in:
Jan Bouwhuis 2024-08-22 19:16:08 +02:00 committed by GitHub
parent 3a92899081
commit 7887bcba89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 146 additions and 8 deletions

View File

@ -39,6 +39,7 @@ CONF_ENCODING = "encoding"
CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" CONF_JSON_ATTRS_TOPIC = "json_attributes_topic"
CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template"
CONF_KEEPALIVE = "keepalive" CONF_KEEPALIVE = "keepalive"
CONF_OPTIONS = "options"
CONF_ORIGIN = "origin" CONF_ORIGIN = "origin"
CONF_QOS = ATTR_QOS CONF_QOS = ATTR_QOS
CONF_RETAIN = ATTR_RETAIN CONF_RETAIN = ATTR_RETAIN

View File

@ -19,7 +19,12 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType
from . import subscription from . import subscription
from .config import MQTT_RW_SCHEMA 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 .mixins import MqttEntity, async_setup_entity_entry_helper
from .models import ( from .models import (
MqttCommandTemplate, MqttCommandTemplate,
@ -32,8 +37,6 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_OPTIONS = "options"
DEFAULT_NAME = "MQTT Select" DEFAULT_NAME = "MQTT Select"
MQTT_SELECT_ATTRIBUTES_BLOCKED = frozenset( MQTT_SELECT_ATTRIBUTES_BLOCKED = frozenset(

View File

@ -38,7 +38,7 @@ from homeassistant.util import dt as dt_util
from . import subscription from . import subscription
from .config import MQTT_RO_SCHEMA 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 .mixins import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper
from .models import ( from .models import (
MqttValueTemplate, 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_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_NAME): vol.Any(cv.string, None), 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_SUGGESTED_DISPLAY_PRECISION): cv.positive_int,
vol.Optional(CONF_STATE_CLASS): vol.Any(STATE_CLASSES_SCHEMA, None), vol.Optional(CONF_STATE_CLASS): vol.Any(STATE_CLASSES_SCHEMA, None),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(cv.string, 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) ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
def validate_sensor_state_class_config(config: ConfigType) -> ConfigType: def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigType:
"""Validate the sensor state class config.""" """Validate the sensor options, state and device class config."""
if ( if (
CONF_LAST_RESET_VALUE_TEMPLATE in config CONF_LAST_RESET_VALUE_TEMPLATE in config
and (state_class := config.get(CONF_STATE_CLASS)) != SensorStateClass.TOTAL 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}`" 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 return config
PLATFORM_SCHEMA_MODERN = vol.All( PLATFORM_SCHEMA_MODERN = vol.All(
_PLATFORM_SCHEMA_BASE, _PLATFORM_SCHEMA_BASE,
validate_sensor_state_class_config, validate_sensor_state_and_device_class_config,
) )
DISCOVERY_SCHEMA = vol.All( DISCOVERY_SCHEMA = vol.All(
_PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), _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 CONF_SUGGESTED_DISPLAY_PRECISION
) )
self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) 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._attr_state_class = config.get(CONF_STATE_CLASS)
self._expire_after = config.get(CONF_EXPIRE_AFTER) self._expire_after = config.get(CONF_EXPIRE_AFTER)
@ -252,6 +272,15 @@ class MqttSensor(MqttEntity, RestoreSensor):
else: else:
self._attr_native_value = payload self._attr_native_value = payload
return 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 { if self.device_class in {
None, None,
SensorDeviceClass.ENUM, SensorDeviceClass.ENUM,

View File

@ -3,6 +3,7 @@
import copy import copy
from datetime import datetime, timedelta from datetime import datetime, timedelta
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch 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" 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( @pytest.mark.parametrize(
"hass_config", "hass_config",
[ [
@ -874,6 +917,61 @@ async def test_invalid_state_class(
assert "expected SensorStateClass or one of" in caplog.text 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( @pytest.mark.parametrize(
"hass_config", "hass_config",
[ [
@ -891,6 +989,13 @@ async def test_invalid_state_class(
"state_topic": "test-topic", "state_topic": "test-topic",
"state_class": None, "state_class": None,
}, },
{
"name": "Test 4",
"state_topic": "test-topic",
"state_class": None,
"device_class": "enum",
"options": ["red", "green", "blue"],
},
] ]
} }
} }