diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 1c94fae9d29..a2bd7fc6b36 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -92,6 +92,7 @@ ABBREVIATIONS = { "name": "name", "off_dly": "off_delay", "on_cmd_type": "on_command_type", + "ops": "options", "opt": "optimistic", "osc_cmd_t": "oscillation_command_topic", "osc_cmd_tpl": "oscillation_command_template", diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index e4f461324a9..d35065e30a8 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -45,6 +45,7 @@ SUPPORTED_COMPONENTS = [ "lock", "number", "scene", + "select", "sensor", "switch", "tag", diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py new file mode 100644 index 00000000000..310b3e508f1 --- /dev/null +++ b/homeassistant/components/mqtt/select.py @@ -0,0 +1,171 @@ +"""Configure select in a device through MQTT topic.""" +import functools +import logging + +import voluptuous as vol + +from homeassistant.components import select +from homeassistant.components.select import SelectEntity +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType + +from . import ( + CONF_COMMAND_TOPIC, + CONF_QOS, + CONF_STATE_TOPIC, + DOMAIN, + PLATFORMS, + subscription, +) +from .. import mqtt +from .const import CONF_RETAIN +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper + +_LOGGER = logging.getLogger(__name__) + +CONF_OPTIONS = "options" + +DEFAULT_NAME = "MQTT Select" +DEFAULT_OPTIMISTIC = False + + +def validate_config(config): + """Validate that the configuration is valid, throws if it isn't.""" + if len(config[CONF_OPTIONS]) < 2: + raise vol.Invalid(f"'{CONF_OPTIONS}' must include at least 2 options") + + return config + + +PLATFORM_SCHEMA = vol.All( + mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Required(CONF_OPTIONS): cv.ensure_list, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + }, + ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), + validate_config, +) + + +async def async_setup_platform( + hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None +): + """Set up MQTT select through configuration.yaml.""" + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + await _async_setup_entity(hass, async_add_entities, config) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up MQTT select dynamically through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, select.DOMAIN, setup, PLATFORM_SCHEMA) + + +async def _async_setup_entity( + hass, async_add_entities, config, config_entry=None, discovery_data=None +): + """Set up the MQTT select.""" + async_add_entities([MqttSelect(hass, config, config_entry, discovery_data)]) + + +class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): + """representation of an MQTT select.""" + + def __init__(self, hass, config, config_entry, discovery_data): + """Initialize the MQTT select.""" + self._config = config + self._optimistic = False + self._sub_state = None + + self._attr_current_option = None + + SelectEntity.__init__(self) + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema(): + """Return the config schema.""" + return PLATFORM_SCHEMA + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._optimistic = config[CONF_OPTIMISTIC] + self._attr_options = config[CONF_OPTIONS] + + value_template = self._config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = self.hass + + 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.""" + payload = msg.payload + value_template = self._config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + payload = value_template.async_render_with_possible_json_value(payload) + + if payload not in self.options: + _LOGGER.error( + "Invalid option for %s: '%s' (valid options: %s)", + self.entity_id, + payload, + self.options, + ) + return + + self._attr_current_option = payload + self.async_write_ha_state() + + if self._config.get(CONF_STATE_TOPIC) is None: + # Force into optimistic mode. + self._optimistic = True + else: + self._sub_state = await subscription.async_subscribe_topics( + self.hass, + self._sub_state, + { + "state_topic": { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + } + }, + ) + + if self._optimistic: + last_state = await self.async_get_last_state() + if last_state: + self._attr_current_option = last_state.state + + async def async_select_option(self, option: str) -> None: + """Update the current value.""" + if self._optimistic: + self._attr_current_option = option + self.async_write_ha_state() + + mqtt.async_publish( + self.hass, + self._config[CONF_COMMAND_TOPIC], + option, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) + + @property + def assumed_state(self): + """Return true if we do optimistic updates.""" + return self._optimistic diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py new file mode 100644 index 00000000000..41fa302a6b9 --- /dev/null +++ b/tests/components/mqtt/test_select.py @@ -0,0 +1,441 @@ +"""The tests for mqtt select component.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components import select +from homeassistant.components.mqtt.select import CONF_OPTIONS +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID +import homeassistant.core as ha +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 = { + select.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "options": ["milk", "beer"], + } +} + + +async def test_run_select_setup(hass, mqtt_mock): + """Test that it fetches the given payload.""" + topic = "test/select" + await async_setup_component( + hass, + "select", + { + "select": { + "platform": "mqtt", + "state_topic": topic, + "command_topic": topic, + "name": "Test Select", + "options": ["milk", "beer"], + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, topic, "milk") + + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.state == "milk" + + async_fire_mqtt_message(hass, topic, "beer") + + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.state == "beer" + + +async def test_value_template(hass, mqtt_mock): + """Test that it fetches the given payload with a template.""" + topic = "test/select" + await async_setup_component( + hass, + "select", + { + "select": { + "platform": "mqtt", + "state_topic": topic, + "command_topic": topic, + "name": "Test Select", + "options": ["milk", "beer"], + "value_template": "{{ value_json.val }}", + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, topic, '{"val":"milk"}') + + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.state == "milk" + + async_fire_mqtt_message(hass, topic, '{"val":"beer"}') + + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.state == "beer" + + +async def test_run_select_service_optimistic(hass, mqtt_mock): + """Test that set_value service works in optimistic mode.""" + topic = "test/select" + + fake_state = ha.State("select.test", "milk") + + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ): + assert await async_setup_component( + hass, + select.DOMAIN, + { + "select": { + "platform": "mqtt", + "command_topic": topic, + "name": "Test Select", + "options": ["milk", "beer"], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.state == "milk" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_select", ATTR_OPTION: "beer"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with(topic, "beer", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("select.test_select") + assert state.state == "beer" + + +async def test_run_select_service(hass, mqtt_mock): + """Test that set_value service works in non optimistic mode.""" + cmd_topic = "test/select/set" + state_topic = "test/select" + + assert await async_setup_component( + hass, + select.DOMAIN, + { + "select": { + "platform": "mqtt", + "command_topic": cmd_topic, + "state_topic": state_topic, + "name": "Test Select", + "options": ["milk", "beer"], + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, state_topic, "beer") + state = hass.states.get("select.test_select") + assert state.state == "beer" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_select", ATTR_OPTION: "milk"}, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with(cmd_topic, "milk", 0, False) + state = hass.states.get("select.test_select") + assert state.state == "beer" + + +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, select.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, select.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, select.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, select.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, select.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, select.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, select.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, select.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, select.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_unique_id(hass, mqtt_mock): + """Test unique id option only creates one select per unique_id.""" + config = { + select.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + "options": ["milk", "beer"], + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + "options": ["milk", "beer"], + }, + ] + } + await help_test_unique_id(hass, mqtt_mock, select.DOMAIN, config) + + +async def test_discovery_removal_select(hass, mqtt_mock, caplog): + """Test removal of discovered select.""" + data = json.dumps(DEFAULT_CONFIG[select.DOMAIN]) + await help_test_discovery_removal(hass, mqtt_mock, caplog, select.DOMAIN, data) + + +async def test_discovery_update_select(hass, mqtt_mock, caplog): + """Test update of discovered select.""" + data1 = '{ "name": "Beer", "state_topic": "test-topic", "command_topic": "test-topic", "options": ["milk", "beer"]}' + data2 = '{ "name": "Milk", "state_topic": "test-topic", "command_topic": "test-topic", "options": ["milk", "beer"]}' + + await help_test_discovery_update( + hass, mqtt_mock, caplog, select.DOMAIN, data1, data2 + ) + + +async def test_discovery_update_unchanged_select(hass, mqtt_mock, caplog): + """Test update of discovered select.""" + data1 = '{ "name": "Beer", "state_topic": "test-topic", "command_topic": "test-topic", "options": ["milk", "beer"]}' + with patch( + "homeassistant.components.mqtt.select.MqttSelect.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, select.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", "state_topic": "test-topic", "command_topic": "test-topic", "options": ["milk", "beer"]}' + + await help_test_discovery_broken( + hass, mqtt_mock, caplog, select.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection(hass, mqtt_mock): + """Test MQTT select device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT select device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, select.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, select.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, select.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, select.DOMAIN, DEFAULT_CONFIG + ) + + +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, select.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, select.DOMAIN, DEFAULT_CONFIG, payload="milk" + ) + + +async def test_options_attributes(hass, mqtt_mock): + """Test options attribute.""" + topic = "test/select" + await async_setup_component( + hass, + "select", + { + "select": { + "platform": "mqtt", + "state_topic": topic, + "command_topic": topic, + "name": "Test select", + "options": ["milk", "beer"], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.attributes.get(ATTR_OPTIONS) == ["milk", "beer"] + + +async def test_invalid_options(hass, caplog, mqtt_mock): + """Test invalid options.""" + topic = "test/select" + await async_setup_component( + hass, + "select", + { + "select": { + "platform": "mqtt", + "state_topic": topic, + "command_topic": topic, + "name": "Test Select", + "options": "beer", + } + }, + ) + await hass.async_block_till_done() + + assert f"'{CONF_OPTIONS}' must include at least 2 options" in caplog.text + + +async def test_mqtt_payload_not_an_option_warning(hass, caplog, mqtt_mock): + """Test warning for MQTT payload which is not a valid option.""" + topic = "test/select" + await async_setup_component( + hass, + "select", + { + "select": { + "platform": "mqtt", + "state_topic": topic, + "command_topic": topic, + "name": "Test Select", + "options": ["milk", "beer"], + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, topic, "öl") + + await hass.async_block_till_done() + + assert ( + "Invalid option for select.test_select: 'öl' (valid options: ['milk', 'beer'])" + in caplog.text + )