Allow to set options for an MQTT enum sensor (#123248)
* Add options attribute support for MQTT sensor * Add commentpull/124436/head^2
parent
3a92899081
commit
7887bcba89
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue