From e574a3ef1d855304b2a78c389861c421b1548d74 Mon Sep 17 00:00:00 2001
From: Jan Bouwhuis <jbouwh@users.noreply.github.com>
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 <erik@montnemery.com>

* await not needed

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Improve casting types and naming

* cleanup_device_registry signature change and black

* remove not needed condition

Co-authored-by: Erik Montnemery <erik@montnemery.com>
---
 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"<Event service_registered[L]: domain=notify, service={service_name}>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_registered[L]: domain=notify, service={service_name}_target1>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_registered[L]: domain=notify, service={service_name}_target2>"
+        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"<Event service_registered[L]: domain=notify, service={service_base_name}>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_registered[L]: domain=notify, service={service_base_name}_t1>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_registered[L]: domain=notify, service={service_base_name}_t2>"
+        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"<Event service_registered[L]: domain=notify, service={service_base_name}>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_registered[L]: domain=notify, service={service_base_name}_t1>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_registered[L]: domain=notify, service={service_base_name}_t2>"
+        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 "<Event service_registered[L]: domain=notify, service=test1>" in caplog.text
+    assert "<Event service_registered[L]: domain=notify, service=test2>" in caplog.text
+    assert (
+        "<Event service_registered[L]: domain=notify, service=test2_t1>" in caplog.text
+    )
+    assert (
+        "<Event service_registered[L]: domain=notify, service=test2_t2>" 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 (
+        "<Event service_registered[L]: domain=notify, service=test_same_name>"
+        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 (
+        "<Event service_registered[L]: domain=notify, service=old_name>" 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 "<Event service_removed[L]: domain=notify, service=old_name>" in caplog.text
+    assert (
+        "<Event service_registered[L]: domain=notify, service=new_name>" 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 "<Event service_removed[L]: domain=notify, service=new_name>" 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 (
+        "<Event service_registered[L]: domain=notify, service=my_notify_service>"
+        in caplog.text
+    )
+    assert (
+        "<Event service_registered[L]: domain=notify, service=my_notify_service_target1>"
+        in caplog.text
+    )
+    assert (
+        "<Event service_registered[L]: domain=notify, service=my_notify_service_target2>"
+        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 (
+        "<Event service_removed[L]: domain=notify, service=my_notify_service_target2>"
+        in caplog.text
+    )
+    assert (
+        "<Event service_registered[L]: domain=notify, service=my_notify_service_target3>"
+        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 "<Event service_registered[L]: domain=notify, service=testa>" 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 "<Event service_registered[L]: domain=notify, service=testb>" 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 "<Event device_registry_updated[L]: action=create, device_id=" in caplog.text
+    # Test device update
+    data_device_update = '{ "command_topic": "test_topic", "name": "My notify service", "targets": ["target1", "target2"], "device":{"identifiers":["LCD_61236812_ADBA"], "name": "Name update" } }'
+    async_fire_mqtt_message(
+        hass, f"homeassistant/{notify.DOMAIN}/{service_name}/config", data_device_update
+    )
+    await hass.async_block_till_done()
+    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 == "Name update"
+
+    # Test removal device from device registry using discovery
+    caplog.clear()
+    async_fire_mqtt_message(
+        hass, f"homeassistant/{notify.DOMAIN}/{service_name}/config", "{}"
+    )
+    await hass.async_block_till_done()
+    device_entry = device_reg.async_get_device({("mqtt", "LCD_61236812_ADBA")})
+    assert device_entry is None
+    assert (
+        "<Event service_removed[L]: domain=notify, service=my_notify_service>"
+        in caplog.text
+    )
+    assert (
+        "<Event service_removed[L]: domain=notify, service=my_notify_service_target1>"
+        in caplog.text
+    )
+    assert (
+        "<Event service_removed[L]: domain=notify, service=my_notify_service_target2>"
+        in caplog.text
+    )
+    assert (
+        f"<Event device_registry_updated[L]: action=remove, device_id={device_id}>"
+        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 "<Event device_registry_updated[L]: action=create, device_id=" in caplog.text
+    await async_setup_notifify_service_with_auto_discovery(
+        hass, mqtt_mock, caplog, device_reg, data2, service_name2
+    )
+    await hass.async_block_till_done()
+    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"
+
+    # Remove fist service
+    caplog.clear()
+    async_fire_mqtt_message(
+        hass, f"homeassistant/{notify.DOMAIN}/{service_name1}/config", "{}"
+    )
+    await hass.async_block_till_done()
+    assert (
+        f"<Event service_removed[L]: domain=notify, service={service_name1}>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_removed[L]: domain=notify, service={service_name1}_target1>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_removed[L]: domain=notify, service={service_name1}_target2>"
+        in caplog.text
+    )
+    assert (
+        f"<Event device_registry_updated[L]: action=remove, device_id={device_id}>"
+        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"<Event service_removed[L]: domain=notify, service={service_name2}>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_removed[L]: domain=notify, service={service_name2}_target1>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_removed[L]: domain=notify, service={service_name2}_target2>"
+        in caplog.text
+    )
+    assert (
+        f"<Event device_registry_updated[L]: action=remove, device_id={device_id}>"
+        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 "<Event device_registry_updated[L]: action=create, device_id=" in caplog.text
+
+    # Test removing the device from the device registry
+    device_entry = device_reg.async_get_device({("mqtt", "LCD_61236812_ADBA")})
+    assert device_entry is not None
+    device_id = device_entry.id
+    caplog.clear()
+    device_reg.async_remove_device(device_id)
+    await hass.async_block_till_done()
+    assert (
+        f"<Event service_removed[L]: domain=notify, service={service_name1}>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_removed[L]: domain=notify, service={service_name1}_target1>"
+        in caplog.text
+    )
+    assert (
+        f"<Event service_removed[L]: domain=notify, service={service_name1}_target2>"
+        in caplog.text
+    )
+    assert (
+        f"<Event device_registry_updated[L]: action=remove, device_id={device_id}>"
+        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 "<Event service_registered[L]: domain=notify, service=test2>" 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 (
+        "<Event service_registered[L]: domain=notify, service=test_old_1>"
+        in caplog.text
+    )
+    assert (
+        "<Event service_registered[L]: domain=notify, service=test_old_2>"
+        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 (
+        "<Event service_registered[L]: domain=notify, service=test_old_3>"
+        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 (
+        "<Event service_removed[L]: domain=notify, service=test_old_1>" in caplog.text
+    )
+    assert (
+        "<Event service_removed[L]: domain=notify, service=test_old_2>" in caplog.text
+    )
+
+    assert (
+        "<Event service_registered[L]: domain=notify, service=test_new_1>"
+        in caplog.text
+    )
+    assert (
+        "<Event service_registered[L]: domain=notify, service=test_new_2>"
+        in caplog.text
+    )
+    assert (
+        "<Event service_registered[L]: domain=notify, service=test_new_3>"
+        in caplog.text
+    )
+    assert "<Event event_mqtt_reloaded[L]>" 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
-- 
GitLab