diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index eb1e8c01ae0..6982e4d728f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -150,6 +150,7 @@ PLATFORMS = [ "fan", "light", "lock", + "number", "scene", "sensor", "switch", diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index eec1a63f932..1e47595058d 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -42,6 +42,7 @@ SUPPORTED_COMPONENTS = [ "fan", "light", "lock", + "number", "scene", "sensor", "switch", diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py new file mode 100644 index 00000000000..f469130cb1c --- /dev/null +++ b/homeassistant/components/mqtt/number.py @@ -0,0 +1,204 @@ +"""Configure number in a device through MQTT topic.""" +import logging + +import voluptuous as vol + +from homeassistant.components import number +from homeassistant.components.number import NumberEntity +from homeassistant.const import CONF_DEVICE, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import ( + ATTR_DISCOVERY_HASH, + CONF_QOS, + DOMAIN, + PLATFORMS, + MqttAttributes, + MqttAvailability, + MqttDiscoveryUpdate, + MqttEntityDeviceInfo, + subscription, +) +from .. import mqtt +from .debug_info import log_messages +from .discovery import MQTT_DISCOVERY_DONE, MQTT_DISCOVERY_NEW, clear_discovery_hash + +_LOGGER = logging.getLogger(__name__) + +CONF_TOPIC = "topic" +DEFAULT_NAME = "MQTT Number" + +PLATFORM_SCHEMA = ( + mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } + ) + .extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) + .extend(mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) +) + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +): + """Set up MQTT number through configuration.yaml.""" + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + await _async_setup_entity(config, async_add_entities) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up MQTT number dynamically through MQTT discovery.""" + + async def async_discover(discovery_payload): + """Discover and add a MQTT number.""" + discovery_data = discovery_payload.discovery_data + try: + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity( + config, async_add_entities, config_entry, discovery_data + ) + except Exception: + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + clear_discovery_hash(hass, discovery_hash) + async_dispatcher_send( + hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None + ) + raise + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format(number.DOMAIN, "mqtt"), async_discover + ) + + +async def _async_setup_entity( + config, async_add_entities, config_entry=None, discovery_data=None +): + """Set up the MQTT number.""" + async_add_entities([MqttNumber(config, config_entry, discovery_data)]) + + +class MqttNumber( + MqttAttributes, + MqttAvailability, + MqttDiscoveryUpdate, + MqttEntityDeviceInfo, + NumberEntity, +): + """representation of an MQTT number.""" + + def __init__(self, config, config_entry, discovery_data): + """Initialize the MQTT Number.""" + self._config = config + self._unique_id = config.get(CONF_UNIQUE_ID) + self._sub_state = None + + self._current_number = None + + device_config = config.get(CONF_DEVICE) + + NumberEntity.__init__(self) + MqttAttributes.__init__(self, config) + MqttAvailability.__init__(self, config) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) + + async def async_added_to_hass(self): + """Subscribe MQTT events.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._config = config + await self.attributes_discovery_update(config) + await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) + await self._subscribe_topics() + self.async_write_ha_state() + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + + @callback + @log_messages(self.hass, self.entity_id) + def message_received(msg): + """Handle new MQTT messages.""" + + try: + if msg.payload.decode("utf-8").isnumeric(): + self._current_number = int(msg.payload) + else: + self._current_number = float(msg.payload) + self.async_write_ha_state() + except ValueError: + _LOGGER.warning("We received <%s> which is not a Number", msg.payload) + + self._sub_state = await subscription.async_subscribe_topics( + self.hass, + self._sub_state, + { + "state_topic": { + "topic": self._config[CONF_TOPIC], + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + "encoding": None, + } + }, + ) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + self._sub_state = await subscription.async_unsubscribe_topics( + self.hass, self._sub_state + ) + await MqttAttributes.async_will_remove_from_hass(self) + await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) + + @property + def value(self): + """Return the current value.""" + return self._current_number + + async def async_set_value(self, value: float) -> None: + """Update the current value.""" + if value.is_integer(): + self._current_number = int(value) + else: + self._current_number = value + + mqtt.async_publish( + self.hass, + self._config[CONF_TOPIC], + self._current_number, + self._config[CONF_QOS], + ) + + self.async_write_ha_state() + + @property + def name(self): + """Return the name of this number.""" + return self._config[CONF_NAME] + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def should_poll(self): + """Return the polling state.""" + return False diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py new file mode 100644 index 00000000000..ac92b842f25 --- /dev/null +++ b/tests/components/mqtt/test_number.py @@ -0,0 +1,263 @@ +"""The tests for mqtt number component.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components import number +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.setup import async_setup_component + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message + +DEFAULT_CONFIG = { + number.DOMAIN: {"platform": "mqtt", "name": "test", "topic": "test_topic"} +} + + +async def test_run_number_setup(hass, mqtt_mock): + """Test that it fetches the given payload.""" + topic = "test/number" + await async_setup_component( + hass, + "number", + {"number": {"platform": "mqtt", "topic": topic, "name": "Test Number"}}, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, topic, "10") + + await hass.async_block_till_done() + + state = hass.states.get("number.test_number") + assert state.state == "10" + + async_fire_mqtt_message(hass, topic, "20.5") + + await hass.async_block_till_done() + + state = hass.states.get("number.test_number") + assert state.state == "20.5" + + +async def test_run_number_service(hass, mqtt_mock): + """Test that set_value service works.""" + topic = "test/number" + await async_setup_component( + hass, + "number", + {"number": {"platform": "mqtt", "topic": topic, "name": "Test Number"}}, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 30}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with(topic, "30", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("number.test_number") + assert state.state == "30" + + +async def test_availability_when_connection_lost(hass, mqtt_mock): + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_availability_without_topic(hass, mqtt_mock): + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload(hass, mqtt_mock): + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload(hass, mqtt_mock): + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_with_template(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_unique_id(hass, mqtt_mock): + """Test unique id option only creates one number per unique_id.""" + config = { + number.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, mqtt_mock, number.DOMAIN, config) + + +async def test_discovery_removal_number(hass, mqtt_mock, caplog): + """Test removal of discovered number.""" + data = json.dumps(DEFAULT_CONFIG[number.DOMAIN]) + await help_test_discovery_removal(hass, mqtt_mock, caplog, number.DOMAIN, data) + + +async def test_discovery_update_number(hass, mqtt_mock, caplog): + """Test update of discovered number.""" + data1 = '{ "name": "Beer", "topic": "test_topic"}' + data2 = '{ "name": "Milk", "topic": "test_topic"}' + + await help_test_discovery_update( + hass, mqtt_mock, caplog, number.DOMAIN, data1, data2 + ) + + +async def test_discovery_update_unchanged_number(hass, mqtt_mock, caplog): + """Test update of discovered number.""" + data1 = '{ "name": "Beer", "topic": "test_topic"}' + with patch( + "homeassistant.components.mqtt.number.MqttNumber.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, number.DOMAIN, data1, discovery_update + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "topic": "test_topic"}' + + await help_test_discovery_broken( + hass, mqtt_mock, caplog, number.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection(hass, mqtt_mock): + """Test MQTT number device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT number device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove(hass, mqtt_mock): + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions(hass, mqtt_mock): + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG, ["test_topic"] + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message(hass, mqtt_mock): + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG, "test_topic", b"ON" + )