From e574a3ef1d855304b2a78c389861c421b1548d74 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 8 Mar 2022 16:27:18 +0100 Subject: [PATCH] Add MQTT notify platform (#64728) * Mqtt Notify service draft * fix updates * Remove TARGET config parameter * do not use protected attributes * complete tests * device support for auto discovery * Add targets attribute and support for data param * Add tests and resolve naming issues * CONF_COMMAND_TEMPLATE from .const * Use mqtt as default service name * make sure service has a unique name * pylint error * fix type error * Conditional device removal and test * Improve tests * update description has_notify_services() * Use TypedDict for service config * casting- fix discovery - hass.data * cleanup * move MqttNotificationConfig after the schemas * fix has_notify_services * do not test log for reg update * Improve casting types * Simplify obtaining the device_id Co-authored-by: Erik Montnemery * await not needed Co-authored-by: Erik Montnemery * Improve casting types and naming * cleanup_device_registry signature change and black * remove not needed condition Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/__init__.py | 1 + .../components/mqtt/abbreviations.py | 2 + homeassistant/components/mqtt/const.py | 12 +- homeassistant/components/mqtt/discovery.py | 12 +- homeassistant/components/mqtt/mixins.py | 19 +- homeassistant/components/mqtt/notify.py | 406 ++++++++ tests/components/mqtt/test_notify.py | 863 ++++++++++++++++++ 7 files changed, 1301 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/mqtt/notify.py create mode 100644 tests/components/mqtt/test_notify.py diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 199b6238770..8ef62ae8bcd 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -148,6 +148,7 @@ PLATFORMS = [ Platform.HUMIDIFIER, Platform.LIGHT, Platform.LOCK, + Platform.NOTIFY, Platform.NUMBER, Platform.SELECT, Platform.SCENE, diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index ddbced5286d..587f9617124 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -185,6 +185,8 @@ ABBREVIATIONS = { "set_fan_spd_t": "set_fan_speed_topic", "set_pos_tpl": "set_position_template", "set_pos_t": "set_position_topic", + "title": "title", + "trgts": "targets", "pos_t": "position_topic", "pos_tpl": "position_template", "spd_cmd_t": "speed_command_topic", diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 69865733763..63b9d68b863 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -1,4 +1,6 @@ """Constants used by multiple MQTT modules.""" +from typing import Final + from homeassistant.const import CONF_PAYLOAD ATTR_DISCOVERY_HASH = "discovery_hash" @@ -12,11 +14,11 @@ ATTR_TOPIC = "topic" CONF_AVAILABILITY = "availability" CONF_BROKER = "broker" CONF_BIRTH_MESSAGE = "birth_message" -CONF_COMMAND_TEMPLATE = "command_template" -CONF_COMMAND_TOPIC = "command_topic" -CONF_ENCODING = "encoding" -CONF_QOS = ATTR_QOS -CONF_RETAIN = ATTR_RETAIN +CONF_COMMAND_TEMPLATE: Final = "command_template" +CONF_COMMAND_TOPIC: Final = "command_topic" +CONF_ENCODING: Final = "encoding" +CONF_QOS: Final = "qos" +CONF_RETAIN: Final = "retain" CONF_STATE_TOPIC = "state_topic" CONF_STATE_VALUE_TEMPLATE = "state_value_template" CONF_TOPIC = "topic" diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 11bc0f6839a..05e06fec666 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -15,6 +15,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.loader import async_get_mqtt from .. import mqtt @@ -48,6 +49,7 @@ SUPPORTED_COMPONENTS = [ "humidifier", "light", "lock", + "notify", "number", "scene", "siren", @@ -232,7 +234,15 @@ async def async_start( # noqa: C901 from . import device_automation await device_automation.async_setup_entry(hass, config_entry) - elif component == "tag": + elif component in "notify": + # Local import to avoid circular dependencies + # pylint: disable=import-outside-toplevel + from . import notify + + await notify.async_setup_entry( + hass, config_entry, AddEntitiesCallback + ) + elif component in "tag": # Local import to avoid circular dependencies # pylint: disable-next=import-outside-toplevel from . import tag diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 9f3722a8f31..c87e5ccba25 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -5,7 +5,7 @@ from abc import abstractmethod from collections.abc import Callable import json import logging -from typing import Any, Protocol +from typing import Any, Protocol, cast import voluptuous as vol @@ -237,10 +237,10 @@ class SetupEntity(Protocol): async def async_setup_entry_helper(hass, domain, async_setup, schema): - """Set up entity, automation or tag creation dynamically through MQTT discovery.""" + """Set up entity, automation, notify service or tag creation dynamically through MQTT discovery.""" async def async_discover(discovery_payload): - """Discover and add an MQTT entity, automation or tag.""" + """Discover and add an MQTT entity, automation, notify service or tag.""" discovery_data = discovery_payload.discovery_data try: config = schema(discovery_payload) @@ -496,11 +496,13 @@ class MqttAvailability(Entity): return self._available_latest -async def cleanup_device_registry(hass, device_id, config_entry_id): - """Remove device registry entry if there are no remaining entities or triggers.""" +async def cleanup_device_registry( + hass: HomeAssistant, device_id: str | None, config_entry_id: str | None +) -> None: + """Remove device registry entry if there are no remaining entities, triggers or notify services.""" # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import device_trigger, tag + # pylint: disable=import-outside-toplevel + from . import device_trigger, notify, tag device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) @@ -511,9 +513,10 @@ async def cleanup_device_registry(hass, device_id, config_entry_id): ) and not await device_trigger.async_get_triggers(hass, device_id) and not tag.async_has_tags(hass, device_id) + and not notify.device_has_notify_services(hass, device_id) ): device_registry.async_update_device( - device_id, remove_config_entry_id=config_entry_id + device_id, remove_config_entry_id=cast(str, config_entry_id) ) diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py new file mode 100644 index 00000000000..9ba341aab0d --- /dev/null +++ b/homeassistant/components/mqtt/notify.py @@ -0,0 +1,406 @@ +"""Support for MQTT notify.""" +from __future__ import annotations + +import functools +import logging +from typing import Any, Final, TypedDict, cast + +import voluptuous as vol + +from homeassistant.components import notify +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify + +from . import PLATFORMS, MqttCommandTemplate +from .. import mqtt +from .const import ( + ATTR_DISCOVERY_HASH, + ATTR_DISCOVERY_PAYLOAD, + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_QOS, + CONF_RETAIN, + DOMAIN, +) +from .discovery import MQTT_DISCOVERY_DONE, MQTT_DISCOVERY_UPDATED, clear_discovery_hash +from .mixins import ( + MQTT_ENTITY_DEVICE_INFO_SCHEMA, + async_setup_entry_helper, + cleanup_device_registry, + device_info_from_config, +) + +CONF_TARGETS: Final = "targets" +CONF_TITLE: Final = "title" +CONF_CONFIG_ENTRY: Final = "config_entry" +CONF_DISCOVER_HASH: Final = "discovery_hash" + +MQTT_NOTIFY_SERVICES_SETUP = "mqtt_notify_services_setup" + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_TARGETS, default=[]): cv.ensure_list, + vol.Optional(CONF_TITLE, default=notify.ATTR_TITLE_DEFAULT): cv.string, + vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, + } +) + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, + }, + extra=vol.REMOVE_EXTRA, +) + +_LOGGER = logging.getLogger(__name__) + + +class MqttNotificationConfig(TypedDict, total=False): + """Supply service parameters for MqttNotificationService.""" + + command_topic: str + command_template: Template + encoding: str + name: str | None + qos: int + retain: bool + targets: list + title: str + device: ConfigType + + +async def async_initialize(hass: HomeAssistant) -> None: + """Initialize globals.""" + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + hass.data.setdefault(MQTT_NOTIFY_SERVICES_SETUP, {}) + + +def device_has_notify_services(hass: HomeAssistant, device_id: str) -> bool: + """Check if the device has registered notify services.""" + if MQTT_NOTIFY_SERVICES_SETUP not in hass.data: + return False + for key, service in hass.data[ # pylint: disable=unused-variable + MQTT_NOTIFY_SERVICES_SETUP + ].items(): + if service.device_id == device_id: + return True + return False + + +def _check_notify_service_name( + hass: HomeAssistant, config: MqttNotificationConfig +) -> str | None: + """Check if the service already exists or else return the service name.""" + service_name = slugify(config[CONF_NAME]) + has_services = hass.services.has_service(notify.DOMAIN, service_name) + services = hass.data[MQTT_NOTIFY_SERVICES_SETUP] + if service_name in services.keys() or has_services: + _LOGGER.error( + "Notify service '%s' already exists, cannot register service", + service_name, + ) + return None + return service_name + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT notify service dynamically through MQTT discovery.""" + await async_initialize(hass) + setup = functools.partial(_async_setup_notify, hass, config_entry=config_entry) + await async_setup_entry_helper(hass, notify.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_notify( + hass, + legacy_config: ConfigType, + config_entry: ConfigEntry, + discovery_data: dict[str, Any], +): + """Set up the MQTT notify service with auto discovery.""" + config: MqttNotificationConfig = DISCOVERY_SCHEMA( + discovery_data[ATTR_DISCOVERY_PAYLOAD] + ) + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + + if not (service_name := _check_notify_service_name(hass, config)): + async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None) + clear_discovery_hash(hass, discovery_hash) + return + + device_id = _update_device(hass, config_entry, config) + + service = MqttNotificationService( + hass, + config, + config_entry, + device_id, + discovery_hash, + ) + hass.data[MQTT_NOTIFY_SERVICES_SETUP][service_name] = service + + await service.async_setup(hass, service_name, service_name) + await service.async_register_services() + + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> MqttNotificationService | None: + """Prepare the MQTT notification service through configuration.yaml.""" + await async_initialize(hass) + notification_config: MqttNotificationConfig = cast(MqttNotificationConfig, config) + + if not (service_name := _check_notify_service_name(hass, notification_config)): + return None + + service = hass.data[MQTT_NOTIFY_SERVICES_SETUP][ + service_name + ] = MqttNotificationService( + hass, + notification_config, + ) + return service + + +class MqttNotificationServiceUpdater: + """Add support for auto discovery updates.""" + + def __init__(self, hass: HomeAssistant, service: MqttNotificationService) -> None: + """Initialize the update service.""" + + async def async_discovery_update( + discovery_payload: DiscoveryInfoType | None, + ) -> None: + """Handle discovery update.""" + if not discovery_payload: + # unregister notify service through auto discovery + async_dispatcher_send( + hass, MQTT_DISCOVERY_DONE.format(service.discovery_hash), None + ) + await async_tear_down_service() + return + + # update notify service through auto discovery + await service.async_update_service(discovery_payload) + _LOGGER.debug( + "Notify service %s updated has been processed", + service.discovery_hash, + ) + async_dispatcher_send( + hass, MQTT_DISCOVERY_DONE.format(service.discovery_hash), None + ) + + async def async_device_removed(event): + """Handle the removal of a device.""" + device_id = event.data["device_id"] + if ( + event.data["action"] != "remove" + or device_id != service.device_id + or self._device_removed + ): + return + self._device_removed = True + await async_tear_down_service() + + async def async_tear_down_service(): + """Handle the removal of the service.""" + services = hass.data[MQTT_NOTIFY_SERVICES_SETUP] + if self._service.service_name in services.keys(): + del services[self._service.service_name] + if not self._device_removed and service.config_entry: + self._device_removed = True + await cleanup_device_registry( + hass, service.device_id, service.config_entry.entry_id + ) + clear_discovery_hash(hass, service.discovery_hash) + self._remove_discovery() + await service.async_unregister_services() + _LOGGER.info( + "Notify service %s has been removed", + service.discovery_hash, + ) + del self._service + + self._service = service + self._remove_discovery = async_dispatcher_connect( + hass, + MQTT_DISCOVERY_UPDATED.format(service.discovery_hash), + async_discovery_update, + ) + if service.device_id: + self._remove_device_updated = hass.bus.async_listen( + EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed + ) + self._device_removed = False + async_dispatcher_send( + hass, MQTT_DISCOVERY_DONE.format(service.discovery_hash), None + ) + _LOGGER.info( + "Notify service %s has been initialized", + service.discovery_hash, + ) + + +class MqttNotificationService(notify.BaseNotificationService): + """Implement the notification service for MQTT.""" + + def __init__( + self, + hass: HomeAssistant, + service_config: MqttNotificationConfig, + config_entry: ConfigEntry | None = None, + device_id: str | None = None, + discovery_hash: tuple | None = None, + ) -> None: + """Initialize the service.""" + self.hass = hass + self._config = service_config + self._commmand_template = MqttCommandTemplate( + service_config.get(CONF_COMMAND_TEMPLATE), hass=hass + ) + self._device_id = device_id + self._discovery_hash = discovery_hash + self._config_entry = config_entry + self._service_name = slugify(service_config[CONF_NAME]) + + self._updater = ( + MqttNotificationServiceUpdater(hass, self) if discovery_hash else None + ) + + @property + def device_id(self) -> str | None: + """Return the device ID.""" + return self._device_id + + @property + def config_entry(self) -> ConfigEntry | None: + """Return the config_entry.""" + return self._config_entry + + @property + def discovery_hash(self) -> tuple | None: + """Return the discovery hash.""" + return self._discovery_hash + + @property + def service_name(self) -> str: + """Return the service ma,e.""" + return self._service_name + + async def async_update_service( + self, + discovery_payload: DiscoveryInfoType, + ) -> None: + """Update the notify service through auto discovery.""" + config: MqttNotificationConfig = DISCOVERY_SCHEMA(discovery_payload) + # Do not rename a service if that service_name is already in use + if ( + new_service_name := slugify(config[CONF_NAME]) + ) != self._service_name and _check_notify_service_name( + self.hass, config + ) is None: + return + # Only refresh services if service name or targets have changes + if ( + new_service_name != self._service_name + or config[CONF_TARGETS] != self._config[CONF_TARGETS] + ): + services = self.hass.data[MQTT_NOTIFY_SERVICES_SETUP] + await self.async_unregister_services() + if self._service_name in services: + del services[self._service_name] + self._config = config + self._service_name = new_service_name + await self.async_register_services() + services[new_service_name] = self + else: + self._config = config + self._commmand_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), hass=self.hass + ) + _update_device(self.hass, self._config_entry, config) + + @property + def targets(self) -> dict[str, str]: + """Return a dictionary of registered targets.""" + return {target: target for target in self._config[CONF_TARGETS]} + + async def async_send_message(self, message: str = "", **kwargs): + """Build and send a MQTT message.""" + target = kwargs.get(notify.ATTR_TARGET) + if ( + target is not None + and self._config[CONF_TARGETS] + and set(target) & set(self._config[CONF_TARGETS]) != set(target) + ): + _LOGGER.error( + "Cannot send %s, target list %s is invalid, valid available targets: %s", + message, + target, + self._config[CONF_TARGETS], + ) + return + variables = { + "message": message, + "name": self._config[CONF_NAME], + "service": self._service_name, + "target": target or self._config[CONF_TARGETS], + "title": kwargs.get(notify.ATTR_TITLE, self._config[CONF_TITLE]), + } + variables.update(kwargs.get(notify.ATTR_DATA) or {}) + payload = self._commmand_template.async_render( + message, + variables=variables, + ) + await mqtt.async_publish( + self.hass, + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + +def _update_device( + hass: HomeAssistant, + config_entry: ConfigEntry | None, + config: MqttNotificationConfig, +) -> str | None: + """Update device registry.""" + if config_entry is None or CONF_DEVICE not in config: + return None + + device = None + device_registry = dr.async_get(hass) + config_entry_id = config_entry.entry_id + device_info = device_info_from_config(config[CONF_DEVICE]) + + if config_entry_id is not None and device_info is not None: + update_device_info = cast(dict, device_info) + update_device_info["config_entry_id"] = config_entry_id + device = device_registry.async_get_or_create(**update_device_info) + + return device.id if device else None diff --git a/tests/components/mqtt/test_notify.py b/tests/components/mqtt/test_notify.py new file mode 100644 index 00000000000..33a32d858af --- /dev/null +++ b/tests/components/mqtt/test_notify.py @@ -0,0 +1,863 @@ +"""The tests for the MQTT button platform.""" +import copy +import json +from unittest.mock import patch + +import pytest +import yaml + +from homeassistant import config as hass_config +from homeassistant.components import notify +from homeassistant.components.mqtt import DOMAIN +from homeassistant.const import CONF_NAME, SERVICE_RELOAD +from homeassistant.exceptions import ServiceNotFound +from homeassistant.setup import async_setup_component +from homeassistant.util import slugify + +from tests.common import async_fire_mqtt_message, mock_device_registry + +DEFAULT_CONFIG = {notify.DOMAIN: {"platform": "mqtt", "command_topic": "test-topic"}} + +COMMAND_TEMPLATE_TEST_PARAMS = ( + "name,service,parameters,expected_result", + [ + ( + "My service", + "my_service", + { + notify.ATTR_TITLE: "Title", + notify.ATTR_MESSAGE: "Message", + notify.ATTR_DATA: {"par1": "val1"}, + }, + '{"message":"Message",' + '"name":"My service",' + '"service":"my_service",' + '"par1":"val1",' + '"target":[' + "'t1', 't2'" + "]," + '"title":"Title"}', + ), + ( + "My service", + "my_service", + { + notify.ATTR_TITLE: "Title", + notify.ATTR_MESSAGE: "Message", + notify.ATTR_DATA: {"par1": "val1"}, + notify.ATTR_TARGET: ["t2"], + }, + '{"message":"Message",' + '"name":"My service",' + '"service":"my_service",' + '"par1":"val1",' + '"target":[' + "'t2'" + "]," + '"title":"Title"}', + ), + ( + "My service", + "my_service_t1", + { + notify.ATTR_TITLE: "Title2", + notify.ATTR_MESSAGE: "Message", + notify.ATTR_DATA: {"par1": "val2"}, + }, + '{"message":"Message",' + '"name":"My service",' + '"service":"my_service",' + '"par1":"val2",' + '"target":[' + "'t1'" + "]," + '"title":"Title2"}', + ), + ], +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +async def async_setup_notifify_service_with_auto_discovery( + hass, mqtt_mock, caplog, device_reg, data, service_name +): + """Test setup notify service with a device config.""" + caplog.clear() + async_fire_mqtt_message( + hass, f"homeassistant/{notify.DOMAIN}/{service_name}/config", data + ) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "LCD_61236812_ADBA")}) + assert device_entry is not None + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + + +@pytest.mark.parametrize(*COMMAND_TEMPLATE_TEST_PARAMS) +async def test_sending_with_command_templates_with_config_setup( + hass, mqtt_mock, caplog, name, service, parameters, expected_result +): + """Test the sending MQTT commands using a template using config setup.""" + config = { + "name": name, + "command_topic": "lcd/set", + "command_template": "{" + '"message":"{{message}}",' + '"name":"{{name}}",' + '"service":"{{service}}",' + '"par1":"{{par1}}",' + '"target":{{target}},' + '"title":"{{title}}"' + "}", + "targets": ["t1", "t2"], + "platform": "mqtt", + "qos": "1", + } + service_base_name = slugify(name) + assert await async_setup_component( + hass, + notify.DOMAIN, + {notify.DOMAIN: config}, + ) + await hass.async_block_till_done() + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + await hass.services.async_call( + notify.DOMAIN, + service, + parameters, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with( + "lcd/set", expected_result, 1, False + ) + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize(*COMMAND_TEMPLATE_TEST_PARAMS) +async def test_sending_with_command_templates_auto_discovery( + hass, mqtt_mock, caplog, name, service, parameters, expected_result +): + """Test the sending MQTT commands using a template and auto discovery.""" + config = { + "name": name, + "command_topic": "lcd/set", + "command_template": "{" + '"message":"{{message}}",' + '"name":"{{name}}",' + '"service":"{{service}}",' + '"par1":"{{par1}}",' + '"target":{{target}},' + '"title":"{{title}}"' + "}", + "targets": ["t1", "t2"], + "qos": "1", + } + if name: + config[CONF_NAME] = name + service_base_name = slugify(name) + else: + service_base_name = DOMAIN + async_fire_mqtt_message( + hass, f"homeassistant/{notify.DOMAIN}/bla/config", json.dumps(config) + ) + await hass.async_block_till_done() + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + await hass.services.async_call( + notify.DOMAIN, + service, + parameters, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with( + "lcd/set", expected_result, 1, False + ) + mqtt_mock.async_publish.reset_mock() + + +async def test_sending_mqtt_commands(hass, mqtt_mock, caplog): + """Test the sending MQTT commands.""" + config1 = { + "command_topic": "command-topic1", + "name": "test1", + "platform": "mqtt", + "qos": "2", + } + config2 = { + "command_topic": "command-topic2", + "name": "test2", + "targets": ["t1", "t2"], + "platform": "mqtt", + "qos": "2", + } + assert await async_setup_component( + hass, + notify.DOMAIN, + {notify.DOMAIN: [config1, config2]}, + ) + await hass.async_block_till_done() + assert "" in caplog.text + assert "" in caplog.text + assert ( + "" in caplog.text + ) + assert ( + "" in caplog.text + ) + + # test1 simple call without targets + await hass.services.async_call( + notify.DOMAIN, + "test1", + {notify.ATTR_TITLE: "Title", notify.ATTR_MESSAGE: "Message"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic1", "Message", 2, False + ) + mqtt_mock.async_publish.reset_mock() + + # test2 simple call without targets + await hass.services.async_call( + notify.DOMAIN, + "test2", + {notify.ATTR_TITLE: "Title", notify.ATTR_MESSAGE: "Message"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic2", "Message", 2, False + ) + mqtt_mock.async_publish.reset_mock() + + # test2 simple call main service without target + await hass.services.async_call( + notify.DOMAIN, + "test2", + {notify.ATTR_TITLE: "Title", notify.ATTR_MESSAGE: "Message"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic2", "Message", 2, False + ) + mqtt_mock.async_publish.reset_mock() + + # test2 simple call main service with empty target + await hass.services.async_call( + notify.DOMAIN, + "test2", + { + notify.ATTR_TITLE: "Title", + notify.ATTR_MESSAGE: "Message", + notify.ATTR_TARGET: [], + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic2", "Message", 2, False + ) + mqtt_mock.async_publish.reset_mock() + + # test2 simple call main service with single target + await hass.services.async_call( + notify.DOMAIN, + "test2", + { + notify.ATTR_TITLE: "Title", + notify.ATTR_MESSAGE: "Message", + notify.ATTR_TARGET: ["t1"], + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic2", "Message", 2, False + ) + mqtt_mock.async_publish.reset_mock() + + # test2 simple call main service with invalid target + await hass.services.async_call( + notify.DOMAIN, + "test2", + { + notify.ATTR_TITLE: "Title", + notify.ATTR_MESSAGE: "Message", + notify.ATTR_TARGET: ["invalid"], + }, + blocking=True, + ) + + assert ( + "Cannot send Message, target list ['invalid'] is invalid, valid available targets: ['t1', 't2']" + in caplog.text + ) + mqtt_mock.async_publish.call_count == 0 + mqtt_mock.async_publish.reset_mock() + + +async def test_with_same_name(hass, mqtt_mock, caplog): + """Test the multiple setups with the same name.""" + config1 = { + "command_topic": "command-topic1", + "name": "test_same_name", + "platform": "mqtt", + "qos": "2", + } + config2 = { + "command_topic": "command-topic2", + "name": "test_same_name", + "targets": ["t1", "t2"], + "platform": "mqtt", + "qos": "2", + } + assert await async_setup_component( + hass, + notify.DOMAIN, + {notify.DOMAIN: [config1, config2]}, + ) + await hass.async_block_till_done() + assert ( + "" + in caplog.text + ) + assert ( + "Notify service 'test_same_name' already exists, cannot register service" + in caplog.text + ) + + # test call main service on service with multiple targets with the same name + # the first configured service should publish + await hass.services.async_call( + notify.DOMAIN, + "test_same_name", + { + notify.ATTR_TITLE: "Title", + notify.ATTR_MESSAGE: "Message", + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic1", "Message", 2, False + ) + mqtt_mock.async_publish.reset_mock() + + with pytest.raises(ServiceNotFound): + await hass.services.async_call( + notify.DOMAIN, + "test_same_name_t2", + { + notify.ATTR_TITLE: "Title", + notify.ATTR_MESSAGE: "Message", + notify.ATTR_TARGET: ["t2"], + }, + blocking=True, + ) + + +async def test_discovery_without_device(hass, mqtt_mock, caplog): + """Test discovery, update and removal of notify service without device.""" + data = '{ "name": "Old name", "command_topic": "test_topic" }' + data_update = '{ "command_topic": "test_topic_update", "name": "New name" }' + data_update_with_targets1 = '{ "command_topic": "test_topic", "name": "My notify service", "targets": ["target1", "target2"] }' + data_update_with_targets2 = '{ "command_topic": "test_topic", "name": "My notify service", "targets": ["target1", "target3"] }' + + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/bla/config", data) + await hass.async_block_till_done() + + assert ( + "" in caplog.text + ) + + await hass.services.async_call( + notify.DOMAIN, + "old_name", + {notify.ATTR_TITLE: "Title", notify.ATTR_MESSAGE: "Message"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with("test_topic", "Message", 0, False) + mqtt_mock.async_publish.reset_mock() + + async_fire_mqtt_message( + hass, f"homeassistant/{notify.DOMAIN}/bla/config", data_update + ) + await hass.async_block_till_done() + + assert "" in caplog.text + assert ( + "" in caplog.text + ) + assert "Notify service ('notify', 'bla') updated has been processed" in caplog.text + + await hass.services.async_call( + notify.DOMAIN, + "new_name", + {notify.ATTR_TITLE: "Title", notify.ATTR_MESSAGE: "Message"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "test_topic_update", "Message", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/bla/config", "") + await hass.async_block_till_done() + + assert "" in caplog.text + + # rediscover with targets + async_fire_mqtt_message( + hass, f"homeassistant/{notify.DOMAIN}/bla/config", data_update_with_targets1 + ) + await hass.async_block_till_done() + + assert ( + "" + in caplog.text + ) + assert ( + "" + in caplog.text + ) + assert ( + "" + in caplog.text + ) + caplog.clear() + + # update available targets + async_fire_mqtt_message( + hass, f"homeassistant/{notify.DOMAIN}/bla/config", data_update_with_targets2 + ) + await hass.async_block_till_done() + + assert ( + "" + in caplog.text + ) + assert ( + "" + in caplog.text + ) + caplog.clear() + + # test if a new service with same name fails to setup + config1 = { + "command_topic": "command-topic-config.yaml", + "name": "test-setup1", + "platform": "mqtt", + "qos": "2", + } + assert await async_setup_component( + hass, + notify.DOMAIN, + {notify.DOMAIN: [config1]}, + ) + await hass.async_block_till_done() + data = '{ "name": "test-setup1", "command_topic": "test_topic" }' + async_fire_mqtt_message( + hass, f"homeassistant/{notify.DOMAIN}/test-setup1/config", data + ) + await hass.async_block_till_done() + assert ( + "Notify service 'test_setup1' already exists, cannot register service" + in caplog.text + ) + await hass.services.async_call( + notify.DOMAIN, + "test_setup1", + { + notify.ATTR_TITLE: "Title", + notify.ATTR_MESSAGE: "Message", + notify.ATTR_TARGET: ["t2"], + }, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with( + "command-topic-config.yaml", "Message", 2, False + ) + + # Test with same discovery on new name + data = '{ "name": "testa", "command_topic": "test_topic_a" }' + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/testa/config", data) + await hass.async_block_till_done() + assert "" in caplog.text + + data = '{ "name": "testb", "command_topic": "test_topic_b" }' + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/testb/config", data) + await hass.async_block_till_done() + assert "" in caplog.text + + # Try to update from new discovery of existing service test + data = '{ "name": "testa", "command_topic": "test_topic_c" }' + caplog.clear() + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/testc/config", data) + await hass.async_block_till_done() + assert ( + "Notify service 'testa' already exists, cannot register service" in caplog.text + ) + + # Try to update the same discovery to existing service test + data = '{ "name": "testa", "command_topic": "test_topic_c" }' + caplog.clear() + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/testb/config", data) + await hass.async_block_till_done() + assert ( + "Notify service 'testa' already exists, cannot register service" in caplog.text + ) + + +async def test_discovery_with_device_update(hass, mqtt_mock, caplog, device_reg): + """Test discovery, update and removal of notify service with a device config.""" + + # Initial setup + data = '{ "command_topic": "test_topic", "name": "My notify service", "targets": ["target1", "target2"], "device":{"identifiers":["LCD_61236812_ADBA"], "name": "Test123" } }' + service_name = "my_notify_service" + await async_setup_notifify_service_with_auto_discovery( + hass, mqtt_mock, caplog, device_reg, data, service_name + ) + assert "" + in caplog.text + ) + assert ( + "" + in caplog.text + ) + assert ( + "" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + + +async def test_discovery_with_device_removal(hass, mqtt_mock, caplog, device_reg): + """Test discovery, update and removal of notify service with a device config.""" + + # Initial setup + data1 = '{ "command_topic": "test_topic", "name": "My notify service1", "targets": ["target1", "target2"], "device":{"identifiers":["LCD_61236812_ADBA"], "name": "Test123" } }' + data2 = '{ "command_topic": "test_topic", "name": "My notify service2", "targets": ["target1", "target2"], "device":{"identifiers":["LCD_61236812_ADBA"], "name": "Test123" } }' + service_name1 = "my_notify_service1" + service_name2 = "my_notify_service2" + await async_setup_notifify_service_with_auto_discovery( + hass, mqtt_mock, caplog, device_reg, data1, service_name1 + ) + assert "" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + assert ( + f"" + not in caplog.text + ) + caplog.clear() + + # The device should still be there + device_entry = device_reg.async_get_device({("mqtt", "LCD_61236812_ADBA")}) + assert device_entry is not None + device_id = device_entry.id + assert device_id == device_entry.id + assert device_entry.name == "Test123" + + # Test removal device from device registry after removing second service + async_fire_mqtt_message( + hass, f"homeassistant/{notify.DOMAIN}/{service_name2}/config", "{}" + ) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "LCD_61236812_ADBA")}) + assert device_entry is None + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + caplog.clear() + + # Recreate the service and device + await async_setup_notifify_service_with_auto_discovery( + hass, mqtt_mock, caplog, device_reg, data1, service_name1 + ) + assert "" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + assert ( + f"" + in caplog.text + ) + + +async def test_publishing_with_custom_encoding(hass, mqtt_mock, caplog): + """Test publishing MQTT payload with different encoding via discovery and configuration.""" + # test with default encoding using configuration setup + assert await async_setup_component( + hass, + notify.DOMAIN, + { + notify.DOMAIN: { + "command_topic": "command-topic", + "name": "test", + "platform": "mqtt", + "qos": "2", + } + }, + ) + await hass.async_block_till_done() + + # test with raw encoding and discovery + data = '{"name": "test2", "command_topic": "test_topic2", "command_template": "{{ pack(int(message), \'b\') }}" }' + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/bla/config", data) + await hass.async_block_till_done() + + assert "Notify service ('notify', 'bla') has been initialized" in caplog.text + assert "" in caplog.text + + await hass.services.async_call( + notify.DOMAIN, + "test2", + {notify.ATTR_TITLE: "Title", notify.ATTR_MESSAGE: "4"}, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with("test_topic2", b"\x04", 0, False) + mqtt_mock.async_publish.reset_mock() + + # test with utf-16 and update discovery + data = '{"encoding":"utf-16", "name": "test3", "command_topic": "test_topic3", "command_template": "{{ message }}" }' + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/bla/config", data) + await hass.async_block_till_done() + assert ( + "Component has already been discovered: notify bla, sending update" + in caplog.text + ) + + await hass.services.async_call( + notify.DOMAIN, + "test3", + {notify.ATTR_TITLE: "Title", notify.ATTR_MESSAGE: "Message"}, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with( + "test_topic3", "Message".encode("utf-16"), 0, False + ) + mqtt_mock.async_publish.reset_mock() + + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/bla/config", "") + await hass.async_block_till_done() + + assert "Notify service ('notify', 'bla') has been removed" in caplog.text + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG[domain] + + # Create and test an old config of 2 entities based on the config supplied + old_config_1 = copy.deepcopy(config) + old_config_1["name"] = "Test old 1" + old_config_2 = copy.deepcopy(config) + old_config_2["name"] = "Test old 2" + + assert await async_setup_component( + hass, domain, {domain: [old_config_1, old_config_2]} + ) + await hass.async_block_till_done() + assert ( + "" + in caplog.text + ) + assert ( + "" + in caplog.text + ) + caplog.clear() + + # Add an auto discovered notify target + data = '{"name": "Test old 3", "command_topic": "test_topic_discovery" }' + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/bla/config", data) + await hass.async_block_till_done() + + assert "Notify service ('notify', 'bla') has been initialized" in caplog.text + assert ( + "" + in caplog.text + ) + + # Create temporary fixture for configuration.yaml based on the supplied config and test a reload with this new config + new_config_1 = copy.deepcopy(config) + new_config_1["name"] = "Test new 1" + new_config_2 = copy.deepcopy(config) + new_config_2["name"] = "test new 2" + new_config_3 = copy.deepcopy(config) + new_config_3["name"] = "test new 3" + new_yaml_config_file = tmp_path / "configuration.yaml" + new_yaml_config = yaml.dump({domain: [new_config_1, new_config_2, new_config_3]}) + new_yaml_config_file.write_text(new_yaml_config) + assert new_yaml_config_file.read_text() == new_yaml_config + + with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert ( + "" in caplog.text + ) + assert ( + "" in caplog.text + ) + + assert ( + "" + in caplog.text + ) + assert ( + "" + in caplog.text + ) + assert ( + "" + in caplog.text + ) + assert "" in caplog.text + caplog.clear() + + # test if the auto discovered item survived the platform reload + await hass.services.async_call( + notify.DOMAIN, + "test_old_3", + {notify.ATTR_TITLE: "Title", notify.ATTR_MESSAGE: "Message"}, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with( + "test_topic_discovery", "Message", 0, False + ) + + mqtt_mock.async_publish.reset_mock() + + async_fire_mqtt_message(hass, f"homeassistant/{notify.DOMAIN}/bla/config", "") + await hass.async_block_till_done() + + assert "Notify service ('notify', 'bla') has been removed" in caplog.text