Improve handling mqtt command template exceptions (#110499)

* Improve handling mqtt command template exceptions

* Fix test

* Cleanup stale exception handler

* Throw on topic template exception
This commit is contained in:
Jan Bouwhuis 2024-02-26 11:04:55 +01:00 committed by GitHub
parent 1f0697e85f
commit 1b2e669302
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 127 additions and 66 deletions

View file

@ -320,49 +320,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
qos: int = call.data[ATTR_QOS] qos: int = call.data[ATTR_QOS]
retain: bool = call.data[ATTR_RETAIN] retain: bool = call.data[ATTR_RETAIN]
if msg_topic_template is not None: if msg_topic_template is not None:
rendered_topic: Any = MqttCommandTemplate(
template.Template(msg_topic_template),
hass=hass,
).async_render()
try: try:
rendered_topic: Any = template.Template(
msg_topic_template, hass
).async_render(parse_result=False)
msg_topic = valid_publish_topic(rendered_topic) msg_topic = valid_publish_topic(rendered_topic)
except TEMPLATE_ERRORS as exc:
_LOGGER.error(
(
"Unable to publish: rendering topic template of %s "
"failed because %s"
),
msg_topic_template,
exc,
)
return
except vol.Invalid as err: except vol.Invalid as err:
_LOGGER.error( err_str = str(err)
( raise ServiceValidationError(
"Unable to publish: topic template '%s' produced an " f"Unable to publish: topic template '{msg_topic_template}' produced an "
"invalid topic '%s' after rendering (%s)" f"invalid topic '{rendered_topic}' after rendering ({err_str})",
), translation_domain=DOMAIN,
msg_topic_template, translation_key="invalid_publish_topic",
rendered_topic, translation_placeholders={
err, "error": err_str,
) "topic": str(rendered_topic),
return "topic_template": str(msg_topic_template),
},
) from err
if payload_template is not None: if payload_template is not None:
try: payload = MqttCommandTemplate(
payload = MqttCommandTemplate( template.Template(payload_template), hass=hass
template.Template(payload_template), hass=hass ).async_render()
).async_render()
except TEMPLATE_ERRORS as exc:
_LOGGER.error(
(
"Unable to publish to %s: rendering payload template of "
"%s failed because %s"
),
msg_topic,
payload_template,
exc,
)
return
if TYPE_CHECKING: if TYPE_CHECKING:
assert msg_topic is not None assert msg_topic is not None

View file

@ -15,6 +15,7 @@ import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
from homeassistant.helpers import template from homeassistant.helpers import template
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
@ -29,7 +30,7 @@ if TYPE_CHECKING:
from .discovery import MQTTDiscoveryPayload from .discovery import MQTTDiscoveryPayload
from .tag import MQTTTagScanner from .tag import MQTTTagScanner
from .const import TEMPLATE_ERRORS from .const import DOMAIN, TEMPLATE_ERRORS
class PayloadSentinel(StrEnum): class PayloadSentinel(StrEnum):
@ -111,6 +112,38 @@ class MqttOriginInfo(TypedDict, total=False):
support_url: str support_url: str
class MqttCommandTemplateException(ServiceValidationError):
"""Handle MqttCommandTemplate exceptions."""
def __init__(
self,
*args: object,
base_exception: Exception,
command_template: str,
value: PublishPayloadType,
entity_id: str | None = None,
) -> None:
"""Initialize exception."""
super().__init__(base_exception, *args)
value_log = str(value)
self.translation_domain = DOMAIN
self.translation_key = "command_template_error"
self.translation_placeholders = {
"error": str(base_exception),
"entity_id": str(entity_id),
"command_template": command_template,
}
entity_id_log = "" if entity_id is None else f" for entity '{entity_id}'"
self._message = (
f"{type(base_exception).__name__}: {base_exception} rendering template{entity_id_log}"
f", template: '{command_template}' and payload: {value_log}"
)
def __str__(self) -> str:
"""Return exception message string."""
return self._message
class MqttCommandTemplate: class MqttCommandTemplate:
"""Class for rendering MQTT payload with command templates.""" """Class for rendering MQTT payload with command templates."""
@ -177,9 +210,17 @@ class MqttCommandTemplate:
values, values,
self._command_template, self._command_template,
) )
return _convert_outgoing_payload( try:
self._command_template.async_render(values, parse_result=False) return _convert_outgoing_payload(
) self._command_template.async_render(values, parse_result=False)
)
except TemplateError as exc:
raise MqttCommandTemplateException(
base_exception=exc,
command_template=self._command_template.template,
value=value,
entity_id=self._entity.entity_id if self._entity is not None else None,
) from exc
class MqttValueTemplate: class MqttValueTemplate:

View file

@ -246,9 +246,15 @@
} }
}, },
"exceptions": { "exceptions": {
"command_template_error": {
"message": "Parsing template `{command_template}` for entity `{entity_id}` failed with error: {error}."
},
"invalid_platform_config": { "invalid_platform_config": {
"message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. See logs for more details." "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": { "mqtt_not_setup_cannot_subscribe": {
"message": "Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly." "message": "Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly."
}, },

View file

@ -16,7 +16,11 @@ from homeassistant.components import mqtt
from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt import debug_info
from homeassistant.components.mqtt.client import EnsureJobAfterCooldown from homeassistant.components.mqtt.client import EnsureJobAfterCooldown
from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA
from homeassistant.components.mqtt.models import MessageCallbackType, ReceiveMessage from homeassistant.components.mqtt.models import (
MessageCallbackType,
MqttCommandTemplateException,
ReceiveMessage,
)
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
ATTR_ASSUMED_STATE, ATTR_ASSUMED_STATE,
@ -30,7 +34,7 @@ from homeassistant.const import (
) )
import homeassistant.core as ha import homeassistant.core as ha
from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.core import CoreState, 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 import device_registry as dr, entity_registry as er, template
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.entity_platform import async_get_platforms
@ -369,6 +373,15 @@ async def test_command_template_variables(
assert state and state.state == "milk" assert state and state.state == "milk"
async def test_command_template_fails(hass: HomeAssistant) -> None:
"""Test the exception handling of an MQTT command template."""
tpl = template.Template("{{ value * 2 }}")
cmd_tpl = mqtt.MqttCommandTemplate(tpl, hass=hass)
with pytest.raises(MqttCommandTemplateException) as exc:
cmd_tpl.async_render(None)
assert "unsupported operand type(s) for *: 'NoneType' and 'int'" in str(exc.value)
async def test_value_template_value(hass: HomeAssistant) -> None: async def test_value_template_value(hass: HomeAssistant) -> None:
"""Test the rendering of MQTT value template.""" """Test the rendering of MQTT value template."""
@ -497,14 +510,20 @@ async def test_service_call_with_invalid_topic_template_does_not_publish(
) -> None: ) -> None:
"""Test the service call with a problematic topic template.""" """Test the service call with a problematic topic template."""
mqtt_mock = await mqtt_mock_entry() mqtt_mock = await mqtt_mock_entry()
await hass.services.async_call( with pytest.raises(MqttCommandTemplateException) as exc:
mqtt.DOMAIN, await hass.services.async_call(
mqtt.SERVICE_PUBLISH, mqtt.DOMAIN,
{ mqtt.SERVICE_PUBLISH,
mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ 1 | no_such_filter }}", {
mqtt.ATTR_PAYLOAD: "payload", mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ 1 | no_such_filter }}",
}, mqtt.ATTR_PAYLOAD: "payload",
blocking=True, },
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 assert not mqtt_mock.async_publish.called
@ -538,14 +557,20 @@ async def test_service_call_with_template_topic_renders_invalid_topic(
If a wildcard topic is rendered, then fail. If a wildcard topic is rendered, then fail.
""" """
mqtt_mock = await mqtt_mock_entry() mqtt_mock = await mqtt_mock_entry()
await hass.services.async_call( with pytest.raises(ServiceValidationError) as exc:
mqtt.DOMAIN, await hass.services.async_call(
mqtt.SERVICE_PUBLISH, mqtt.DOMAIN,
{ mqtt.SERVICE_PUBLISH,
mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ '+' if True else 'topic' }}/topic", {
mqtt.ATTR_PAYLOAD: "payload", mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ '+' if True else 'topic' }}/topic",
}, mqtt.ATTR_PAYLOAD: "payload",
blocking=True, },
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 assert not mqtt_mock.async_publish.called
@ -611,13 +636,21 @@ async def test_service_call_with_bad_template(
) -> None: ) -> None:
"""Test the service call with a bad template does not publish.""" """Test the service call with a bad template does not publish."""
mqtt_mock = await mqtt_mock_entry() mqtt_mock = await mqtt_mock_entry()
await hass.services.async_call( with pytest.raises(MqttCommandTemplateException) as exc:
mqtt.DOMAIN, await hass.services.async_call(
mqtt.SERVICE_PUBLISH, mqtt.DOMAIN,
{mqtt.ATTR_TOPIC: "test/topic", mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ 1 | bad }}"}, mqtt.SERVICE_PUBLISH,
blocking=True, {
) mqtt.ATTR_TOPIC: "test/topic",
mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ 1 | bad }}",
},
blocking=True,
)
assert not mqtt_mock.async_publish.called assert not mqtt_mock.async_publish.called
assert str(exc.value) == (
"TemplateError: TemplateAssertionError: No filter named 'bad'. "
"rendering template, template: '{{ 1 | bad }}' and payload: None"
)
async def test_service_call_with_payload_doesnt_render_template( async def test_service_call_with_payload_doesnt_render_template(