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:
parent
1f0697e85f
commit
1b2e669302
4 changed files with 127 additions and 66 deletions
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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."
|
||||||
},
|
},
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Add table
Reference in a new issue