From 843fae825f19613b781cac1f0f087fc499372589 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 17 Jul 2024 17:56:34 +0200 Subject: [PATCH] Revert "Remove stale `template_topic` code for mqtt publish service" (#121758) Revert "Remove stale `template_topic` code for mqtt publish service (#121604)" This reverts commit 5b25c24539c82cc3c014fde744a3f678e98890f0. --- homeassistant/components/mqtt/__init__.py | 26 +++++- homeassistant/components/mqtt/strings.json | 3 + tests/components/mqtt/test_init.py | 96 +++++++++++++++++++++- 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 891989fa09b..f057dab8bc4 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -113,6 +113,7 @@ _LOGGER = logging.getLogger(__name__) SERVICE_PUBLISH = "publish" SERVICE_DUMP = "dump" +ATTR_TOPIC_TEMPLATE = "topic_template" ATTR_PAYLOAD_TEMPLATE = "payload_template" MAX_RECONNECT_WAIT = 300 # seconds @@ -158,14 +159,16 @@ CONFIG_SCHEMA = vol.Schema( MQTT_PUBLISH_SCHEMA = vol.All( vol.Schema( { - vol.Required(ATTR_TOPIC): valid_publish_topic, + vol.Exclusive(ATTR_TOPIC, CONF_TOPIC): valid_publish_topic, + vol.Exclusive(ATTR_TOPIC_TEMPLATE, CONF_TOPIC): cv.string, vol.Exclusive(ATTR_PAYLOAD, CONF_PAYLOAD): cv.string, vol.Exclusive(ATTR_PAYLOAD_TEMPLATE, CONF_PAYLOAD): cv.string, vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema, vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, }, required=True, - ) + ), + cv.has_at_least_one_key(ATTR_TOPIC, ATTR_TOPIC_TEMPLATE), ) @@ -287,10 +290,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_publish_service(call: ServiceCall) -> None: """Handle MQTT publish service calls.""" msg_topic: str | None = call.data.get(ATTR_TOPIC) + msg_topic_template: str | None = call.data.get(ATTR_TOPIC_TEMPLATE) payload: PublishPayloadType = call.data.get(ATTR_PAYLOAD) payload_template: str | None = call.data.get(ATTR_PAYLOAD_TEMPLATE) qos: int = call.data[ATTR_QOS] retain: bool = call.data[ATTR_RETAIN] + if msg_topic_template is not None: + rendered_topic: Any = MqttCommandTemplate( + template.Template(msg_topic_template), + hass=hass, + ).async_render() + try: + msg_topic = valid_publish_topic(rendered_topic) + except vol.Invalid as err: + err_str = str(err) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_publish_topic", + translation_placeholders={ + "error": err_str, + "topic": str(rendered_topic), + "topic_template": str(msg_topic_template), + }, + ) from err if payload_template is not None: payload = MqttCommandTemplate( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 4d4b5e7cb44..f1e740d7f35 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -270,6 +270,9 @@ "invalid_platform_config": { "message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. See logs for more details." }, + "invalid_publish_topic": { + "message": "Unable to publish: topic template `{topic_template}` produced an invalid topic `{topic}` after rendering ({error})" + }, "mqtt_not_setup_cannot_subscribe": { "message": "Cannot subscribe to topic \"{topic}\", make sure MQTT is set up correctly." }, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 3aa378cb4d8..403f7974878 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er, template from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms @@ -260,6 +260,100 @@ async def test_service_call_without_topic_does_not_publish( assert not mqtt_mock.async_publish.called +async def test_service_call_with_topic_and_topic_template_does_not_publish( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the service call with topic/topic template. + + If both 'topic' and 'topic_template' are provided then fail. + """ + mqtt_mock = await mqtt_mock_entry() + topic = "test/topic" + topic_template = "test/{{ 'topic' }}" + with pytest.raises(vol.Invalid): + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC: topic, + mqtt.ATTR_TOPIC_TEMPLATE: topic_template, + mqtt.ATTR_PAYLOAD: "payload", + }, + blocking=True, + ) + assert not mqtt_mock.async_publish.called + + +async def test_service_call_with_invalid_topic_template_does_not_publish( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the service call with a problematic topic template.""" + mqtt_mock = await mqtt_mock_entry() + with pytest.raises(MqttCommandTemplateException) as exc: + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ 1 | no_such_filter }}", + mqtt.ATTR_PAYLOAD: "payload", + }, + blocking=True, + ) + assert str(exc.value) == ( + "TemplateError: TemplateAssertionError: No filter named 'no_such_filter'. " + "rendering template, template: " + "'test/{{ 1 | no_such_filter }}' and payload: None" + ) + assert not mqtt_mock.async_publish.called + + +async def test_service_call_with_template_topic_renders_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the service call with rendered topic template. + + If 'topic_template' is provided and 'topic' is not, then render it. + """ + mqtt_mock = await mqtt_mock_entry() + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ 1+1 }}", + mqtt.ATTR_PAYLOAD: "payload", + }, + blocking=True, + ) + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][0] == "test/2" + + +async def test_service_call_with_template_topic_renders_invalid_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the service call with rendered, invalid topic template. + + If a wildcard topic is rendered, then fail. + """ + mqtt_mock = await mqtt_mock_entry() + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ '+' if True else 'topic' }}/topic", + mqtt.ATTR_PAYLOAD: "payload", + }, + blocking=True, + ) + assert str(exc.value) == ( + "Unable to publish: topic template `test/{{ '+' if True else 'topic' }}/topic` " + "produced an invalid topic `test/+/topic` after rendering " + "(Wildcards cannot be used in topic names)" + ) + assert not mqtt_mock.async_publish.called + + async def test_service_call_with_invalid_rendered_template_topic_doesnt_render_template( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: