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

* Add options attribute support for MQTT sensor

* Add comment
pull/124436/head^2
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_TEMPLATE = "json_attributes_template"
CONF_KEEPALIVE = "keepalive"
CONF_OPTIONS = "options"
CONF_ORIGIN = "origin"
CONF_QOS = ATTR_QOS
CONF_RETAIN = ATTR_RETAIN

View File

@ -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(

View File

@ -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,

View File

@ -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"],
},
]
}
}