diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 8bc318e4897..a5360090bb9 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -249,6 +249,8 @@ ABBREVIATIONS = { "t": "topic", "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", + "url_t": "url_topic", + "url_tpl": "url_template", "val_tpl": "value_template", "whit_cmd_t": "white_command_topic", "whit_scl": "white_scale", diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 4b6519f744b..2764539770d 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -28,10 +28,10 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_QOS +from .const import CONF_ENCODING, CONF_QOS from .debug_info import log_messages from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper -from .models import ReceiveMessage +from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -39,21 +39,41 @@ _LOGGER = logging.getLogger(__name__) CONF_CONTENT_TYPE = "content_type" CONF_IMAGE_ENCODING = "image_encoding" CONF_IMAGE_TOPIC = "image_topic" +CONF_URL_TEMPLATE = "url_template" +CONF_URL_TOPIC = "url_topic" DEFAULT_NAME = "MQTT Image" +GET_IMAGE_TIMEOUT = 10 -PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( + +def validate_topic_required(config: ConfigType) -> ConfigType: + """Ensure at least one subscribe topic is configured.""" + if CONF_IMAGE_TOPIC not in config and CONF_URL_TOPIC not in config: + raise vol.Invalid("Expected one of [`image_topic`, `url_topic`], got none") + if CONF_CONTENT_TYPE in config and CONF_URL_TOPIC in config: + raise vol.Invalid( + "Option `content_type` can not be used together with `url_topic`" + ) + return config + + +PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( { - vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string, + vol.Optional(CONF_CONTENT_TYPE): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_IMAGE_TOPIC): valid_subscribe_topic, + vol.Exclusive(CONF_URL_TOPIC, "image_topic"): valid_subscribe_topic, + vol.Exclusive(CONF_IMAGE_TOPIC, "image_topic"): valid_subscribe_topic, vol.Optional(CONF_IMAGE_ENCODING): "b64", + vol.Optional(CONF_URL_TEMPLATE): cv.template, } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) +PLATFORM_SCHEMA_MODERN = vol.All(PLATFORM_SCHEMA_BASE.schema, validate_topic_required) -DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) +DISCOVERY_SCHEMA = vol.All( + PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), validate_topic_required +) async def async_setup_entry( @@ -107,14 +127,45 @@ class MqttImage(MqttEntity, ImageEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._topic = {key: config.get(key) for key in (CONF_IMAGE_TOPIC,)} - self._attr_content_type = config[CONF_CONTENT_TYPE] + self._topic = { + key: config.get(key) + for key in ( + CONF_IMAGE_TOPIC, + CONF_URL_TOPIC, + ) + } + if CONF_IMAGE_TOPIC in config: + self._attr_content_type = config.get( + CONF_CONTENT_TYPE, DEFAULT_CONTENT_TYPE + ) + if CONF_URL_TOPIC in config: + self._attr_image_url = None + self._url_template = MqttValueTemplate( + config.get(CONF_URL_TEMPLATE), entity=self + ).async_render_with_possible_json_value def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} + def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: + """Add a topic to subscribe to.""" + encoding: str | None + encoding = ( + None + if CONF_IMAGE_TOPIC in self._config + else self._config[CONF_ENCODING] or None + ) + if has_topic := self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": self._config[CONF_QOS], + "encoding": encoding, + } + return has_topic + @callback @log_messages(self.hass, self.entity_id) def image_data_received(msg: ReceiveMessage) -> None: @@ -135,12 +186,27 @@ class MqttImage(MqttEntity, ImageEntity): self._attr_image_last_updated = dt_util.utcnow() get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - topics[self._config[CONF_IMAGE_TOPIC]] = { - "topic": self._config[CONF_IMAGE_TOPIC], - "msg_callback": image_data_received, - "qos": self._config[CONF_QOS], - "encoding": None, - } + add_subscribe_topic(CONF_IMAGE_TOPIC, image_data_received) + + @callback + @log_messages(self.hass, self.entity_id) + def image_from_url_request_received(msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + + try: + url = cv.url(self._url_template(msg.payload)) + self._attr_image_url = url + except vol.Invalid: + _LOGGER.error( + "Invalid image URL '%s' received at topic %s", + msg.payload, + msg.topic, + ) + self._attr_image_last_updated = dt_util.utcnow() + self._cached_image = None + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscribe_topic(CONF_URL_TOPIC, image_from_url_request_received) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics @@ -152,4 +218,6 @@ class MqttImage(MqttEntity, ImageEntity): async def async_image(self) -> bytes | None: """Return bytes of image.""" - return self._last_image + if CONF_IMAGE_TOPIC in self._config: + return self._last_image + return await super().async_image() diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index 8cb7739cd7e..032289b6ce7 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -3,8 +3,10 @@ from base64 import b64encode from contextlib import suppress from http import HTTPStatus import json -from unittest.mock import patch +import ssl +from unittest.mock import MagicMock, patch +import httpx import pytest import respx @@ -197,11 +199,278 @@ async def test_image_b64_encoded_with_availability( assert state.state == "2023-04-01T00:00:00+00:00" +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "image": { + "url_topic": "test/image", + "name": "Test", + } + } + } + ], +) +async def test_image_from_url( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with URL.""" + respx.get("http://localhost/test.png").respond( + status_code=HTTPStatus.OK, content_type="image/png", content=b"milk" + ) + topic = "test/image" + + await mqtt_mock_entry() + + # Test first with invalid URL + async_fire_mqtt_message(hass, topic, b"/tmp/test.png") + await hass.async_block_till_done() + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + + assert "Invalid image URL" in caplog.text + + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + async_fire_mqtt_message(hass, topic, b"http://localhost/test.png") + + await hass.async_block_till_done() + + client = await hass_client_no_auth() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.text() + assert body == "milk" + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "image": { + "url_topic": "test/image", + "name": "Test", + "url_template": "{{ value_json.val }}", + } + } + } + ], +) +async def test_image_from_url_with_template( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test setup with URL.""" + respx.get("http://localhost/test.png").respond( + status_code=HTTPStatus.OK, content_type="image/png", content=b"milk" + ) + topic = "test/image" + + await mqtt_mock_entry() + + state = hass.states.get("image.test") + assert state.state == STATE_UNKNOWN + + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + async_fire_mqtt_message(hass, topic, '{"val": "http://localhost/test.png"}') + + await hass.async_block_till_done() + + client = await hass_client_no_auth() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.text() + assert body == "milk" + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "image": { + "url_topic": "test/image", + "name": "Test", + } + } + } + ], +) +@pytest.mark.parametrize( + ("content_type", "setup_ok"), + [ + ("image/jpg", True), + ("image", True), + ("image/png", True), + ("text/javascript", False), + ], +) +async def test_image_from_url_content_type( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + content_type: str, + setup_ok: bool, +) -> None: + """Test setup with URL.""" + respx.get("http://localhost/test.png").respond( + status_code=HTTPStatus.OK, content_type=content_type, content=b"milk" + ) + topic = "test/image" + + await mqtt_mock_entry() + + # Test first with invalid URL + async_fire_mqtt_message(hass, topic, b"/tmp/test.png") + await hass.async_block_till_done() + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + async_fire_mqtt_message(hass, topic, b"http://localhost/test.png") + + await hass.async_block_till_done() + + client = await hass_client_no_auth() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK if setup_ok else HTTPStatus.SERVICE_UNAVAILABLE + if setup_ok: + body = await resp.text() + assert body == "milk" + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" if setup_ok else STATE_UNKNOWN + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "image": { + "url_topic": "test/image", + "name": "Test", + "encoding": "utf-8", + } + } + } + ], +) +@pytest.mark.parametrize( + "side_effect", + [ + httpx.RequestError("server offline", request=MagicMock()), + httpx.TimeoutException, + ssl.SSLError, + ], +) +async def test_image_from_url_fails( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + side_effect: Exception, +) -> None: + """Test setup with minimum configuration.""" + respx.get("http://localhost/test.png").mock(side_effect=side_effect) + topic = "test/image" + + await mqtt_mock_entry() + + state = hass.states.get("image.test") + assert state.state == STATE_UNKNOWN + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + async_fire_mqtt_message(hass, topic, b"http://localhost/test.png") + + await hass.async_block_till_done() + + state = hass.states.get("image.test") + + # The image failed to load, the the last image update is registered + # but _last_image was set to `None` + assert state.state == "2023-04-01T00:00:00+00:00" + client = await hass_client_no_auth() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + @respx.mock @pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") @pytest.mark.parametrize( ("hass_config", "error_msg"), [ + ( + { + mqtt.DOMAIN: { + "image": { + "url_topic": "test/image", + "content_type": "image/jpg", + "name": "Test", + "encoding": "utf-8", + } + } + }, + "Option `content_type` can not be used together with `url_topic`", + ), + ( + { + mqtt.DOMAIN: { + "image": { + "url_topic": "test/image", + "image_topic": "test/image-data-topic", + "name": "Test", + "encoding": "utf-8", + } + } + }, + "two or more values in the same group of exclusion 'image_topic'", + ), ( { mqtt.DOMAIN: { @@ -211,7 +480,7 @@ async def test_image_b64_encoded_with_availability( } } }, - "Invalid config for [mqtt]: required key not provided @ data['mqtt']['image'][0]['image_topic']. Got None.", + "Invalid config for [mqtt]: Expected one of [`image_topic`, `url_topic`], got none", ), ], )