From 51edc007fe8aec1b7abd9902e9d7699d55490da6 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 26 Jun 2023 15:09:17 +0000 Subject: [PATCH 1/2] Add url support for mqtt image platform --- .../components/mqtt/abbreviations.py | 2 + homeassistant/components/mqtt/image.py | 108 ++++++- tests/components/mqtt/test_image.py | 272 +++++++++++++++++- 3 files changed, 366 insertions(+), 16 deletions(-) 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..750ee944045 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -6,6 +6,7 @@ import binascii from collections.abc import Callable import functools import logging +import ssl from typing import Any import httpx @@ -28,10 +29,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 +40,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 +128,60 @@ 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 + ) + self._url_template = MqttValueTemplate( + config.get(CONF_URL_TEMPLATE), entity=self + ).async_render_with_possible_json_value + + async def _async_load_image(self, url: str) -> None: + try: + response = await self._client.request( + "GET", url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True + ) + except (httpx.TimeoutException, httpx.RequestError, ssl.SSLError) as ex: + _LOGGER.warning("Connection failed to url %s files: %s", url, ex) + self._last_image = None + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() + return + + self._attr_content_type = response.headers["content-type"] + self._last_image = response.content + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() 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 +202,25 @@ 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)) + except vol.Invalid: + _LOGGER.error( + "Invalid image URL '%s' received at topic %s", + msg.payload, + msg.topic, + ) + return + self.hass.async_create_task(self._async_load_image(url)) + + 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 diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index 8cb7739cd7e..dfe44cc461f 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,277 @@ 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 == STATE_UNKNOWN + + 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 == 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() + + 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", "log_text"), + [ + (httpx.RequestError("server offline", request=MagicMock()), "server offline"), + (httpx.TimeoutException, "Connection failed"), + (ssl.SSLError, "Connection failed"), + ], +) +async def test_image_from_url_fails( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + side_effect: Exception, + log_text: str, +) -> 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" + assert log_text in caplog.text + + @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 +479,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", ), ], ) From 5f3bcee97e490c09a71127dadca0e663c8ed38bc Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 26 Jun 2023 22:14:55 +0000 Subject: [PATCH 2/2] Refactor url fetch code to use base platform --- homeassistant/components/mqtt/image.py | 33 ++++++++++---------------- tests/components/mqtt/test_image.py | 17 ++++++------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 750ee944045..fdb0c6d01a4 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -6,7 +6,6 @@ import binascii from collections.abc import Callable import functools import logging -import ssl from typing import Any import httpx @@ -106,6 +105,7 @@ class MqttImage(MqttEntity, ImageEntity): _entity_id_format: str = image.ENTITY_ID_FORMAT _last_image: bytes | None = None _client: httpx.AsyncClient + _url: str | None = None _url_template: Callable[[ReceivePayloadType], ReceivePayloadType] _topic: dict[str, Any] @@ -143,23 +143,6 @@ class MqttImage(MqttEntity, ImageEntity): config.get(CONF_URL_TEMPLATE), entity=self ).async_render_with_possible_json_value - async def _async_load_image(self, url: str) -> None: - try: - response = await self._client.request( - "GET", url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True - ) - except (httpx.TimeoutException, httpx.RequestError, ssl.SSLError) as ex: - _LOGGER.warning("Connection failed to url %s files: %s", url, ex) - self._last_image = None - self._attr_image_last_updated = dt_util.utcnow() - self.async_write_ha_state() - return - - self._attr_content_type = response.headers["content-type"] - self._last_image = response.content - self._attr_image_last_updated = dt_util.utcnow() - self.async_write_ha_state() - def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -211,14 +194,16 @@ class MqttImage(MqttEntity, ImageEntity): try: url = cv.url(self._url_template(msg.payload)) + self._url = url except vol.Invalid: _LOGGER.error( "Invalid image URL '%s' received at topic %s", msg.payload, msg.topic, ) - return - self.hass.async_create_task(self._async_load_image(url)) + self._last_image = None + self._attr_image_last_updated = dt_util.utcnow() + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_URL_TOPIC, image_from_url_request_received) @@ -232,4 +217,10 @@ 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() + + async def async_image_url(self) -> str | None: + """Return URL of image.""" + return self._url diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index dfe44cc461f..032289b6ce7 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -233,7 +233,7 @@ async def test_image_from_url( await hass.async_block_till_done() state = hass.states.get("image.test") - assert state.state == STATE_UNKNOWN + assert state.state == "2023-04-01T00:00:00+00:00" assert "Invalid image URL" in caplog.text @@ -356,7 +356,7 @@ async def test_image_from_url_content_type( await hass.async_block_till_done() state = hass.states.get("image.test") - assert state.state == STATE_UNKNOWN + assert state.state == "2023-04-01T00:00:00+00:00" access_token = state.attributes["access_token"] assert state.attributes == { @@ -397,11 +397,11 @@ async def test_image_from_url_content_type( ], ) @pytest.mark.parametrize( - ("side_effect", "log_text"), + "side_effect", [ - (httpx.RequestError("server offline", request=MagicMock()), "server offline"), - (httpx.TimeoutException, "Connection failed"), - (ssl.SSLError, "Connection failed"), + httpx.RequestError("server offline", request=MagicMock()), + httpx.TimeoutException, + ssl.SSLError, ], ) async def test_image_from_url_fails( @@ -410,7 +410,6 @@ async def test_image_from_url_fails( mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, side_effect: Exception, - log_text: str, ) -> None: """Test setup with minimum configuration.""" respx.get("http://localhost/test.png").mock(side_effect=side_effect) @@ -436,7 +435,9 @@ async def test_image_from_url_fails( # 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" - assert log_text in caplog.text + client = await hass_client_no_auth() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR @respx.mock