mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 21:27:38 +00:00
Allow to set options for an MQTT enum sensor (#123248)
* Add options attribute support for MQTT sensor * Add comment
This commit is contained in:
parent
3a92899081
commit
7887bcba89
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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"],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user