diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 2500923ca9b..7244a41e975 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -45,6 +45,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.LAWN_MOWER.value: vol.All(cv.ensure_list, [dict]), Platform.LIGHT.value: vol.All(cv.ensure_list, [dict]), Platform.LOCK.value: vol.All(cv.ensure_list, [dict]), + Platform.NOTIFY.value: vol.All(cv.ensure_list, [dict]), Platform.NUMBER.value: vol.All(cv.ensure_list, [dict]), Platform.SCENE.value: vol.All(cv.ensure_list, [dict]), Platform.SELECT.value: vol.All(cv.ensure_list, [dict]), diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 82320cd2f11..7eca266edfa 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -157,6 +157,7 @@ RELOADABLE_PLATFORMS = [ Platform.LIGHT, Platform.LAWN_MOWER, Platform.LOCK, + Platform.NOTIFY, Platform.NUMBER, Platform.SCENE, Platform.SELECT, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 43f4f8cfd46..e330cd9b44b 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -66,6 +66,7 @@ SUPPORTED_COMPONENTS = { "lawn_mower", "light", "lock", + "notify", "number", "scene", "siren", diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py new file mode 100644 index 00000000000..b7a17f07f7f --- /dev/null +++ b/homeassistant/components/mqtt/notify.py @@ -0,0 +1,95 @@ +"""Support for MQTT notify.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components import notify +from homeassistant.components.notify import NotifyEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType + +from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_QOS, + CONF_RETAIN, +) +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) +from .models import MqttCommandTemplate +from .util import valid_publish_topic + +DEFAULT_NAME = "MQTT notify" + +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT notify through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttNotify, + notify.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttNotify(MqttEntity, NotifyEntity): + """Representation of a notification entity service that can send messages using MQTT.""" + + _default_name = DEFAULT_NAME + _entity_id_format = notify.ENTITY_ID_FORMAT + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), entity=self + ).async_render + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + async def async_send_message(self, message: str) -> None: + """Send a message.""" + payload = self._command_template(message) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) diff --git a/tests/components/mqtt/test_notify.py b/tests/components/mqtt/test_notify.py new file mode 100644 index 00000000000..bc833b79eb0 --- /dev/null +++ b/tests/components/mqtt/test_notify.py @@ -0,0 +1,474 @@ +"""The tests for the MQTT notify platform.""" + +import copy +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, notify +from homeassistant.components.notify import ATTR_MESSAGE +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +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_publishing_with_custom_encoding, + help_test_reloadable, + 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_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: {notify.DOMAIN: {"name": "test", "command_topic": "test-topic"}} +} + + +@pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + notify.DOMAIN: { + "command_topic": "command-topic", + "name": "test", + "object_id": "test_notify", + "qos": "2", + } + } + } + ], +) +async def test_sending_mqtt_commands( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("notify.test_notify") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test" + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "Beer message", ATTR_ENTITY_ID: "notify.test_notify"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "Beer message", 2, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("notify.test_notify") + assert state.state == "2021-11-08T13:31:44+00:00" + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + notify.DOMAIN: { + "command_topic": "command-topic", + "command_template": '{ "{{ entity_id }}": "{{ value }}" }', + "name": "test", + } + } + } + ], +) +async def test_command_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending of MQTT commands through a command template.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("notify.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test" + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "Beer message", ATTR_ENTITY_ID: "notify.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", '{ "notify.test": "Beer message" }', 0, False + ) + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, notify.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG, True + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, + mqtt_mock_entry, + notify.DOMAIN, + DEFAULT_CONFIG, + True, + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + notify.DOMAIN: [ + { + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one notify entity per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, notify.DOMAIN) + + +async def test_discovery_removal_notify( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered notify.""" + data = '{ "name": "test", "command_topic": "test_topic" }' + await help_test_discovery_removal( + hass, mqtt_mock_entry, caplog, notify.DOMAIN, data + ) + + +async def test_discovery_update_notify( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered notify.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][notify.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][notify.DOMAIN]) + config1["name"] = "Beer" + config2["name"] = "Milk" + + await help_test_discovery_update( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + config1, + config2, + ) + + +async def test_discovery_update_unchanged_notify( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered notify.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.notify.MqttNotify.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, notify.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT notify device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT notify device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + notify.DOMAIN, + DEFAULT_CONFIG, + notify.SERVICE_SEND_MESSAGE, + command_topic="test-topic", + command_payload="Milk", + state_topic=None, + service_parameters={"message": "Milk"}, + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + notify.SERVICE_SEND_MESSAGE, + "command_topic", + {"message": "Beer test"}, + "Beer test", + "command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, +) -> None: + """Test publishing MQTT payload with different encoding.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = notify.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + )