diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 05d7d0adb86..73caf023ef6 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -145,6 +145,7 @@ PLATFORMS = [ "fan", "light", "lock", + "scene", "sensor", "switch", "vacuum", diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 7f9a6730285..d1e64d44bbc 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -37,6 +37,7 @@ SUPPORTED_COMPONENTS = [ "fan", "light", "lock", + "scene", "sensor", "switch", "tag", diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py new file mode 100644 index 00000000000..4f4380332fd --- /dev/null +++ b/homeassistant/components/mqtt/scene.py @@ -0,0 +1,144 @@ +"""Support for MQTT scenes.""" +import logging + +import voluptuous as vol + +from homeassistant.components import mqtt, scene +from homeassistant.components.scene import Scene +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_PAYLOAD_ON, CONF_UNIQUE_ID +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import ( + ATTR_DISCOVERY_HASH, + CONF_COMMAND_TOPIC, + CONF_QOS, + CONF_RETAIN, + DOMAIN, + PLATFORMS, + MqttAvailability, + MqttDiscoveryUpdate, +) +from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "MQTT Scene" +DEFAULT_RETAIN = False + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_ON): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + } +).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +): + """Set up MQTT scene 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 scene dynamically through MQTT discovery.""" + + async def async_discover(discovery_payload): + """Discover and add a MQTT scene.""" + 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: + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) + raise + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format(scene.DOMAIN, "mqtt"), async_discover + ) + + +async def _async_setup_entity( + config, async_add_entities, config_entry=None, discovery_data=None +): + """Set up the MQTT scene.""" + async_add_entities([MqttScene(config, config_entry, discovery_data)]) + + +class MqttScene( + MqttAvailability, + MqttDiscoveryUpdate, + Scene, +): + """Representation of a scene that can be activated using MQTT.""" + + def __init__(self, config, config_entry, discovery_data): + """Initialize the MQTT scene.""" + self._state = False + self._sub_state = None + + self._unique_id = config.get(CONF_UNIQUE_ID) + + # Load config + self._setup_from_config(config) + + MqttAvailability.__init__(self, config) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + self.async_write_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._config = config + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) + + @property + def name(self): + """Return the name of the scene.""" + return self._config[CONF_NAME] + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def icon(self): + """Return the icon.""" + return self._config.get(CONF_ICON) + + async def async_activate(self, **kwargs): + """Activate the scene. + + This method is a coroutine. + """ + mqtt.async_publish( + self.hass, + self._config[CONF_COMMAND_TOPIC], + self._config[CONF_PAYLOAD_ON], + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py new file mode 100644 index 00000000000..0e3341bd15f --- /dev/null +++ b/tests/components/mqtt/test_scene.py @@ -0,0 +1,181 @@ +"""The tests for the MQTT scene platform.""" +import copy +import json + +import pytest + +from homeassistant.components import scene +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON +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_unchanged, + help_test_unique_id, +) + +from tests.async_mock import patch + +DEFAULT_CONFIG = { + scene.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "payload_on": "test-payload-on", + } +} + + +async def test_sending_mqtt_commands(hass, mqtt_mock): + """Test the sending MQTT commands.""" + fake_state = ha.State("scene.test", scene.STATE) + + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ): + assert await async_setup_component( + hass, + scene.DOMAIN, + { + scene.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_on": "beer on", + }, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("scene.test") + assert state.state == scene.STATE + + data = {ATTR_ENTITY_ID: "scene.test"} + await hass.services.async_call(scene.DOMAIN, SERVICE_TURN_ON, data, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "beer on", 0, False + ) + + +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, scene.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, scene.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload(hass, mqtt_mock): + """Test availability by default payload with defined topic.""" + config = { + scene.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_on": 1, + } + } + + await help_test_default_availability_payload( + hass, mqtt_mock, scene.DOMAIN, config, True, "state-topic", "1" + ) + + +async def test_custom_availability_payload(hass, mqtt_mock): + """Test availability by custom payload with defined topic.""" + config = { + scene.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_on": 1, + } + } + + await help_test_custom_availability_payload( + hass, mqtt_mock, scene.DOMAIN, config, True, "state-topic", "1" + ) + + +async def test_unique_id(hass, mqtt_mock): + """Test unique id option only creates one scene per unique_id.""" + config = { + scene.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, mqtt_mock, scene.DOMAIN, config) + + +async def test_discovery_removal_scene(hass, mqtt_mock, caplog): + """Test removal of discovered scene.""" + data = '{ "name": "test",' ' "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock, caplog, scene.DOMAIN, data) + + +async def test_discovery_update_payload(hass, mqtt_mock, caplog): + """Test update of discovered scene.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[scene.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[scene.DOMAIN]) + config1["name"] = "Beer" + config2["name"] = "Milk" + config1["payload_on"] = "ON" + config2["payload_on"] = "ACTIVATE" + + data1 = json.dumps(config1) + data2 = json.dumps(config2) + await help_test_discovery_update( + hass, + mqtt_mock, + caplog, + scene.DOMAIN, + data1, + data2, + ) + + +async def test_discovery_update_unchanged_scene(hass, mqtt_mock, caplog): + """Test update of discovered scene.""" + data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.scene.MqttScene.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, scene.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",' ' "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock, caplog, scene.DOMAIN, data1, data2 + )