Add url support for mqtt image platform

This commit is contained in:
jbouwh 2023-06-26 15:09:17 +00:00 committed by Erik
parent 98cc45ec10
commit 51edc007fe
3 changed files with 366 additions and 16 deletions

View File

@ -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",

View File

@ -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

View File

@ -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",
),
],
)