From b5ce84cd89868d18cceddf6eeff434c707a50d46 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 9 Nov 2021 12:04:14 +0100 Subject: [PATCH] Add MQTT button (#59348) --- .../components/mqtt/abbreviations.py | 1 + homeassistant/components/mqtt/button.py | 88 ++++++ homeassistant/components/mqtt/discovery.py | 1 + tests/components/mqtt/test_button.py | 266 ++++++++++++++++++ tests/components/mqtt/test_discovery.py | 7 + 5 files changed, 363 insertions(+) create mode 100644 homeassistant/components/mqtt/button.py create mode 100644 tests/components/mqtt/test_button.py diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 71a8d109c3f..1daa6f837c7 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -135,6 +135,7 @@ ABBREVIATIONS = { "pl_osc_off": "payload_oscillation_off", "pl_osc_on": "payload_oscillation_on", "pl_paus": "payload_pause", + "pl_prs": "payload_press", "pl_rst": "payload_reset", "pl_rst_hum": "payload_reset_humidity", "pl_rst_mode": "payload_reset_mode", diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py new file mode 100644 index 00000000000..4006b8bfab9 --- /dev/null +++ b/homeassistant/components/mqtt/button.py @@ -0,0 +1,88 @@ +"""Support for MQTT buttons.""" +from __future__ import annotations + +import functools + +import voluptuous as vol + +from homeassistant.components import button +from homeassistant.components.button import ButtonEntity +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.typing import ConfigType + +from . import PLATFORMS +from .. import mqtt +from .const import CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, DOMAIN +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper + +CONF_PAYLOAD_PRESS = "payload_press" +DEFAULT_NAME = "MQTT Button" +DEFAULT_PAYLOAD_PRESS = "PRESS" + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_PRESS, default=DEFAULT_PAYLOAD_PRESS): cv.string, + vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_platform( + hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None +): + """Set up MQTT button 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 button dynamically through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, button.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass, async_add_entities, config, config_entry=None, discovery_data=None +): + """Set up the MQTT button.""" + async_add_entities([MqttButton(hass, config, config_entry, discovery_data)]) + + +class MqttButton(MqttEntity, ButtonEntity): + """Representation of a switch that can be toggled using MQTT.""" + + _entity_id_format = button.ENTITY_ID_FORMAT + + def __init__(self, hass, config, config_entry, discovery_data): + """Initialize the MQTT button.""" + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema(): + """Return the config schema.""" + return DISCOVERY_SCHEMA + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + + async def async_press(self, **kwargs): + """Turn the device on. + + This method is a coroutine. + """ + await mqtt.async_publish( + self.hass, + self._config[CONF_COMMAND_TOPIC], + self._config[CONF_PAYLOAD_PRESS], + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index d8ab9193cc4..d490374ed53 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -38,6 +38,7 @@ TOPIC_MATCHER = re.compile( SUPPORTED_COMPONENTS = [ "alarm_control_panel", "binary_sensor", + "button", "camera", "climate", "cover", diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py new file mode 100644 index 00000000000..0a1d8af2a0c --- /dev/null +++ b/tests/components/mqtt/test_button.py @@ -0,0 +1,266 @@ +"""The tests for the MQTT button platform.""" +import copy +from unittest.mock import patch + +import pytest + +from homeassistant.components import button +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN +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_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_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, +) + +DEFAULT_CONFIG = { + button.DOMAIN: {"platform": "mqtt", "name": "test", "command_topic": "test-topic"} +} + + +@pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") +async def test_sending_mqtt_commands(hass, mqtt_mock): + """Test the sending MQTT commands.""" + assert await async_setup_component( + hass, + button.DOMAIN, + { + button.DOMAIN: { + "command_topic": "command-topic", + "name": "test", + "object_id": "test_button", + "payload_press": "beer press", + "platform": "mqtt", + "qos": "2", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.test_button") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test" + + await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_button"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "beer press", 2, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("button.test_button") + assert state.state == "2021-11-08T13:31:44+00:00" + + +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, button.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, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload(hass, mqtt_mock): + """Test availability by default payload with defined topic.""" + config = { + button.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_press": 1, + } + } + + await help_test_default_availability_payload( + hass, mqtt_mock, button.DOMAIN, config, True, "state-topic", "1" + ) + + +async def test_custom_availability_payload(hass, mqtt_mock): + """Test availability by custom payload with defined topic.""" + config = { + button.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_press": 1, + } + } + + await help_test_custom_availability_payload( + hass, mqtt_mock, button.DOMAIN, config, True, "state-topic", "1" + ) + + +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, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG, None + ) + + +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, button.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, button.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, button.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, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_unique_id(hass, mqtt_mock): + """Test unique id option only creates one button per unique_id.""" + config = { + button.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, button.DOMAIN, config) + + +async def test_discovery_removal_button(hass, mqtt_mock, caplog): + """Test removal of discovered button.""" + data = '{ "name": "test", "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock, caplog, button.DOMAIN, data) + + +async def test_discovery_update_button(hass, mqtt_mock, caplog): + """Test update of discovered button.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[button.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[button.DOMAIN]) + config1["name"] = "Beer" + config2["name"] = "Milk" + + await help_test_discovery_update( + hass, + mqtt_mock, + caplog, + button.DOMAIN, + config1, + config2, + ) + + +async def test_discovery_update_unchanged_button(hass, mqtt_mock, caplog): + """Test update of discovered button.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.button.MqttButton.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, button.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, button.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection(hass, mqtt_mock): + """Test MQTT button device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT button device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, button.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, button.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, button.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, button.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 08eb4b882c5..a97047195fb 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -208,6 +208,13 @@ async def test_discover_alarm_control_panel(hass, mqtt_mock, caplog): "Hello World 2", "binary_sensor", ), + ( + "homeassistant/button/object/bla/config", + '{ "name": "Hello World button", "obj_id": "hello_id", "command_topic": "test-topic" }', + "button.hello_id", + "Hello World button", + "button", + ), ( "homeassistant/camera/object/bla/config", '{ "name": "Hello World 3", "obj_id": "hello_id", "state_topic": "test-topic", "topic": "test-topic" }',