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>
This commit is contained in:
Jan Bouwhuis 2022-03-08 16:27:18 +01:00 committed by GitHub
parent 13ac6e62e2
commit e574a3ef1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 1301 additions and 14 deletions

View File

@ -148,6 +148,7 @@ PLATFORMS = [
Platform.HUMIDIFIER, Platform.HUMIDIFIER,
Platform.LIGHT, Platform.LIGHT,
Platform.LOCK, Platform.LOCK,
Platform.NOTIFY,
Platform.NUMBER, Platform.NUMBER,
Platform.SELECT, Platform.SELECT,
Platform.SCENE, Platform.SCENE,

View File

@ -185,6 +185,8 @@ ABBREVIATIONS = {
"set_fan_spd_t": "set_fan_speed_topic", "set_fan_spd_t": "set_fan_speed_topic",
"set_pos_tpl": "set_position_template", "set_pos_tpl": "set_position_template",
"set_pos_t": "set_position_topic", "set_pos_t": "set_position_topic",
"title": "title",
"trgts": "targets",
"pos_t": "position_topic", "pos_t": "position_topic",
"pos_tpl": "position_template", "pos_tpl": "position_template",
"spd_cmd_t": "speed_command_topic", "spd_cmd_t": "speed_command_topic",

View File

@ -1,4 +1,6 @@
"""Constants used by multiple MQTT modules.""" """Constants used by multiple MQTT modules."""
from typing import Final
from homeassistant.const import CONF_PAYLOAD from homeassistant.const import CONF_PAYLOAD
ATTR_DISCOVERY_HASH = "discovery_hash" ATTR_DISCOVERY_HASH = "discovery_hash"
@ -12,11 +14,11 @@ ATTR_TOPIC = "topic"
CONF_AVAILABILITY = "availability" CONF_AVAILABILITY = "availability"
CONF_BROKER = "broker" CONF_BROKER = "broker"
CONF_BIRTH_MESSAGE = "birth_message" CONF_BIRTH_MESSAGE = "birth_message"
CONF_COMMAND_TEMPLATE = "command_template" CONF_COMMAND_TEMPLATE: Final = "command_template"
CONF_COMMAND_TOPIC = "command_topic" CONF_COMMAND_TOPIC: Final = "command_topic"
CONF_ENCODING = "encoding" CONF_ENCODING: Final = "encoding"
CONF_QOS = ATTR_QOS CONF_QOS: Final = "qos"
CONF_RETAIN = ATTR_RETAIN CONF_RETAIN: Final = "retain"
CONF_STATE_TOPIC = "state_topic" CONF_STATE_TOPIC = "state_topic"
CONF_STATE_VALUE_TEMPLATE = "state_value_template" CONF_STATE_VALUE_TEMPLATE = "state_value_template"
CONF_TOPIC = "topic" CONF_TOPIC = "topic"

View File

@ -15,6 +15,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_connect,
async_dispatcher_send, async_dispatcher_send,
) )
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.loader import async_get_mqtt from homeassistant.loader import async_get_mqtt
from .. import mqtt from .. import mqtt
@ -48,6 +49,7 @@ SUPPORTED_COMPONENTS = [
"humidifier", "humidifier",
"light", "light",
"lock", "lock",
"notify",
"number", "number",
"scene", "scene",
"siren", "siren",
@ -232,7 +234,15 @@ async def async_start( # noqa: C901
from . import device_automation from . import device_automation
await device_automation.async_setup_entry(hass, config_entry) 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 # Local import to avoid circular dependencies
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from . import tag from . import tag

View File

@ -5,7 +5,7 @@ from abc import abstractmethod
from collections.abc import Callable from collections.abc import Callable
import json import json
import logging import logging
from typing import Any, Protocol from typing import Any, Protocol, cast
import voluptuous as vol import voluptuous as vol
@ -237,10 +237,10 @@ class SetupEntity(Protocol):
async def async_setup_entry_helper(hass, domain, async_setup, schema): 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): 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 discovery_data = discovery_payload.discovery_data
try: try:
config = schema(discovery_payload) config = schema(discovery_payload)
@ -496,11 +496,13 @@ class MqttAvailability(Entity):
return self._available_latest return self._available_latest
async def cleanup_device_registry(hass, device_id, config_entry_id): async def cleanup_device_registry(
"""Remove device registry entry if there are no remaining entities or triggers.""" 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 # Local import to avoid circular dependencies
# pylint: disable-next=import-outside-toplevel # pylint: disable=import-outside-toplevel
from . import device_trigger, tag from . import device_trigger, notify, tag
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
entity_registry = er.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 await device_trigger.async_get_triggers(hass, device_id)
and not tag.async_has_tags(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_registry.async_update_device(
device_id, remove_config_entry_id=config_entry_id device_id, remove_config_entry_id=cast(str, config_entry_id)
) )

View File

@ -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

View File

@ -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