From 6e087039f47f5eac3bddee31301ea30d384fba01 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 21 May 2021 18:35:27 +0300 Subject: [PATCH] Add min/max/step to MQTT number (#50869) --- .../components/mqtt/abbreviations.py | 3 + homeassistant/components/mqtt/number.py | 80 ++++++++++++-- tests/components/mqtt/test_number.py | 104 ++++++++++++++++++ 3 files changed, 176 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 5d34c92c1b1..4eef2d372ae 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -75,6 +75,8 @@ ABBREVIATIONS = { "json_attr": "json_attributes", "json_attr_t": "json_attributes_topic", "json_attr_tpl": "json_attributes_template", + "max": "max", + "min": "min", "max_mirs": "max_mireds", "min_mirs": "min_mireds", "max_temp": "max_temp", @@ -170,6 +172,7 @@ ABBREVIATIONS = { "stat_t": "state_topic", "stat_tpl": "state_template", "stat_val_tpl": "state_value_template", + "step": "step", "stype": "subtype", "sup_feat": "supported_features", "sup_clrm": "supported_color_modes", diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index dd4cfb47acb..95409924fa4 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -5,7 +5,12 @@ import logging import voluptuous as vol from homeassistant.components import number -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import ( + DEFAULT_MAX_VALUE, + DEFAULT_MIN_VALUE, + DEFAULT_STEP, + NumberEntity, +) from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -28,15 +33,36 @@ from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_hel _LOGGER = logging.getLogger(__name__) +CONF_MIN = "min" +CONF_MAX = "max" +CONF_STEP = "step" + DEFAULT_NAME = "MQTT Number" DEFAULT_OPTIMISTIC = False -PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } -).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +def validate_config(config): + """Validate that the configuration is valid, throws if it isn't.""" + if config.get(CONF_MIN) >= config.get(CONF_MAX): + raise vol.Invalid(f"'{CONF_MAX}'' must be > '{CONF_MIN}'") + + return config + + +PLATFORM_SCHEMA = vol.All( + mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): vol.Coerce(float), + vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): vol.All( + vol.Coerce(float), vol.Range(min=1e-3) + ), + }, + ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), + validate_config, +) async def async_setup_platform( @@ -67,6 +93,7 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): def __init__(self, config, config_entry, discovery_data): """Initialize the MQTT Number.""" + self._config = config self._sub_state = None self._current_number = None @@ -89,12 +116,28 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): """Handle new MQTT messages.""" try: if msg.payload.decode("utf-8").isnumeric(): - self._current_number = int(msg.payload) + num_value = int(msg.payload) else: - self._current_number = float(msg.payload) - self.async_write_ha_state() + num_value = float(msg.payload) except ValueError: - _LOGGER.warning("We received <%s> which is not a Number", msg.payload) + _LOGGER.warning( + "Payload '%s' is not a Number", + msg.payload.decode("utf-8", errors="ignore"), + ) + return + + if num_value < self.min_value or num_value > self.max_value: + _LOGGER.error( + "Invalid value for %s: %s (range %s - %s)", + self.entity_id, + num_value, + self.min_value, + self.max_value, + ) + return + + self._current_number = num_value + self.async_write_ha_state() if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. @@ -118,6 +161,21 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): if last_state: self._current_number = last_state.state + @property + def min_value(self) -> float: + """Return the minimum value.""" + return self._config[CONF_MIN] + + @property + def max_value(self) -> float: + """Return the maximum value.""" + return self._config[CONF_MAX] + + @property + def step(self) -> float: + """Return the increment/decrement step.""" + return self._config[CONF_STEP] + @property def value(self): """Return the current value.""" diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index ac5285e9855..d93b0483865 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -5,7 +5,11 @@ from unittest.mock import patch import pytest from homeassistant.components import number +from homeassistant.components.mqtt.number import CONF_MAX, CONF_MIN from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -357,3 +361,103 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG, payload=b"1" ) + + +async def test_min_max_step_attributes(hass, mqtt_mock): + """Test min/max/step attributes.""" + topic = "test/number" + await async_setup_component( + hass, + "number", + { + "number": { + "platform": "mqtt", + "state_topic": topic, + "command_topic": topic, + "name": "Test Number", + "min": 5, + "max": 110, + "step": 20, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.test_number") + assert state.attributes.get(ATTR_MIN) == 5 + assert state.attributes.get(ATTR_MAX) == 110 + assert state.attributes.get(ATTR_STEP) == 20 + + +async def test_invalid_min_max_attributes(hass, caplog, mqtt_mock): + """Test invalid min/max attributes.""" + topic = "test/number" + await async_setup_component( + hass, + "number", + { + "number": { + "platform": "mqtt", + "state_topic": topic, + "command_topic": topic, + "name": "Test Number", + "min": 35, + "max": 10, + } + }, + ) + await hass.async_block_till_done() + + assert f"'{CONF_MAX}'' must be > '{CONF_MIN}'" in caplog.text + + +async def test_mqtt_payload_not_a_number_warning(hass, caplog, mqtt_mock): + """Test warning for MQTT payload which is not a number.""" + topic = "test/number" + await async_setup_component( + hass, + "number", + { + "number": { + "platform": "mqtt", + "state_topic": topic, + "command_topic": topic, + "name": "Test Number", + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, topic, "not_a_number") + + await hass.async_block_till_done() + + assert "Payload 'not_a_number' is not a Number" in caplog.text + + +async def test_mqtt_payload_out_of_range_error(hass, caplog, mqtt_mock): + """Test error when MQTT payload is out of min/max range.""" + topic = "test/number" + await async_setup_component( + hass, + "number", + { + "number": { + "platform": "mqtt", + "state_topic": topic, + "command_topic": topic, + "name": "Test Number", + "min": 5, + "max": 110, + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, topic, "115.5") + + await hass.async_block_till_done() + + assert ( + "Invalid value for number.test_number: 115.5 (range 5.0 - 110.0)" in caplog.text + )