diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 366f989b2925..26b6cd7cd457 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -39,6 +39,7 @@ from homeassistant.components.climate import ( from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.image import DEFAULT_CONTENT_TYPE from homeassistant.components.light import ( DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, @@ -167,6 +168,7 @@ from .const import ( CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_CONTENT_TYPE, CONF_CURRENT_HUMIDITY_TEMPLATE, CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TEMPLATE, @@ -205,6 +207,8 @@ from .const import ( CONF_HUMIDITY_MIN, CONF_HUMIDITY_STATE_TEMPLATE, CONF_HUMIDITY_STATE_TOPIC, + CONF_IMAGE_ENCODING, + CONF_IMAGE_TOPIC, CONF_KEEPALIVE, CONF_LAST_RESET_VALUE_TEMPLATE, CONF_MAX_KELVIN, @@ -330,6 +334,8 @@ from .const import ( CONF_TLS_INSECURE, CONF_TRANSITION, CONF_TRANSPORT, + CONF_URL_TEMPLATE, + CONF_URL_TOPIC, CONF_WHITE_COMMAND_TOPIC, CONF_WHITE_SCALE, CONF_WILL_MESSAGE, @@ -434,6 +440,7 @@ SUBENTRY_PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.FAN, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.NOTIFY, @@ -620,6 +627,43 @@ HUMIDITY_SELECTOR = vol.All( ), vol.Coerce(int), ) +IMAGE_CONTENT_TYPE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value="image/jpeg", label="Joint Photographic Expert Group image (JPEG)" + ), + SelectOptionDict( + value="image/png", label="Portable Network Graphics (PNG)" + ), + SelectOptionDict( + value="image/apng", label="Animated Portable Network Graphics (APNG)" + ), + SelectOptionDict(value="image/avif", label="AV1 Image File Format (AVIF)"), + SelectOptionDict( + value="image/gif", label="Graphics Interchange Format (GIF)" + ), + SelectOptionDict( + value="image/svg+xml", label="Scalable Vector Graphics (SVG)" + ), + SelectOptionDict(value="image/webp", label="Web Picture format (WEBP)"), + ], + mode=SelectSelectorMode.DROPDOWN, + ) +) +IMAGE_ENCODING_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["raw", "b64"], + translation_key="image_encoding", + mode=SelectSelectorMode.DROPDOWN, + ) +) +IMAGE_PROCESSING_MODE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["image_url", "image_data"], + translation_key="image_processing_mode", + ) +) KELVIN_SELECTOR = NumberSelector( NumberSelectorConfig( mode=NumberSelectorMode.BOX, @@ -1019,6 +1063,7 @@ ENTITY_CONFIG_VALIDATOR: dict[ Platform.CLIMATE.value: validate_climate_platform_config, Platform.COVER.value: validate_cover_platform_config, Platform.FAN.value: validate_fan_platform_config, + Platform.IMAGE.value: None, Platform.LIGHT.value: validate_light_platform_config, Platform.LOCK.value: None, Platform.NOTIFY.value: None, @@ -1209,6 +1254,18 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { default=lambda config: bool(config.get(CONF_DIRECTION_COMMAND_TOPIC)), ), }, + Platform.IMAGE.value: { + "image_processing_mode": PlatformField( + selector=IMAGE_PROCESSING_MODE_SELECTOR, + required=True, + exclude_from_config=True, + default=( + lambda config: "image_url" + if config.get(CONF_IMAGE_TOPIC) is None + else "image_data" + ), + ) + }, Platform.LIGHT.value: { CONF_SCHEMA: PlatformField( selector=LIGHT_SCHEMA_SELECTOR, @@ -2292,6 +2349,40 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = { conditions=({"fan_feature_direction": True},), ), }, + Platform.IMAGE.value: { + CONF_IMAGE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({"image_processing_mode": "image_data"},), + ), + CONF_CONTENT_TYPE: PlatformField( + selector=IMAGE_CONTENT_TYPE_SELECTOR, + required=True, + default=DEFAULT_CONTENT_TYPE, + conditions=({"image_processing_mode": "image_data"},), + ), + CONF_IMAGE_ENCODING: PlatformField( + selector=IMAGE_ENCODING_SELECTOR, + required=False, + conditions=({"image_processing_mode": "image_data"},), + default="raw", + ), + CONF_URL_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({"image_processing_mode": "image_url"},), + ), + CONF_URL_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + }, Platform.LIGHT.value: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 90f484b1a90e..d16617ef2a4e 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -38,9 +38,12 @@ CONF_CODE_FORMAT = "code_format" CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required" CONF_COMMAND_TEMPLATE = "command_template" CONF_COMMAND_TOPIC = "command_topic" +CONF_CONTENT_TYPE = "content_type" CONF_DEFAULT_ENTITY_ID = "default_entity_id" CONF_DISCOVERY_PREFIX = "discovery_prefix" CONF_ENCODING = "encoding" +CONF_IMAGE_ENCODING = "image_encoding" +CONF_IMAGE_TOPIC = "image_topic" CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" CONF_KEEPALIVE = "keepalive" @@ -231,6 +234,8 @@ CONF_TILT_MIN = "tilt_min" CONF_TILT_OPEN_POSITION = "tilt_opened_value" CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic" CONF_TRANSITION = "transition" +CONF_URL_TEMPLATE = "url_template" +CONF_URL_TOPIC = "url_topic" CONF_XY_COMMAND_TEMPLATE = "xy_command_template" CONF_XY_COMMAND_TOPIC = "xy_command_topic" CONF_XY_STATE_TOPIC = "xy_state_topic" diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index a668608dd551..5e84e83bf692 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -25,6 +25,13 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_BASE_SCHEMA +from .const import ( + CONF_CONTENT_TYPE, + CONF_IMAGE_ENCODING, + CONF_IMAGE_TOPIC, + CONF_URL_TEMPLATE, + CONF_URL_TOPIC, +) from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( DATA_MQTT, @@ -39,12 +46,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -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 @@ -67,7 +68,7 @@ PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_NAME): vol.Any(cv.string, None), 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_IMAGE_ENCODING): vol.In({"b64", "raw"}), vol.Optional(CONF_URL_TEMPLATE): cv.template, } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) @@ -146,7 +147,7 @@ class MqttImage(MqttEntity, ImageEntity): def _image_data_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" try: - if CONF_IMAGE_ENCODING in self._config: + if self._config.get(CONF_IMAGE_ENCODING) == "b64": self._last_image = b64decode(msg.payload) else: if TYPE_CHECKING: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 3eadb2f5917a..7f14f26e8792 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -264,6 +264,7 @@ "fan_feature_preset_modes": "Preset modes support", "fan_feature_oscillation": "Oscillation support", "fan_feature_direction": "Direction support", + "image_processing_mode": "Image processing mode", "options": "Add option", "schema": "Schema", "state_class": "State class", @@ -290,6 +291,7 @@ "fan_feature_preset_modes": "The fan supports preset modes.", "fan_feature_oscillation": "The fan supports oscillation.", "fan_feature_direction": "The fan supports direction.", + "image_processing_mode": "Select how the image data is received.", "options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement.", "schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)", "state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", @@ -326,8 +328,11 @@ "command_topic": "Command topic", "command_off_template": "Command \"off\" template", "command_on_template": "Command \"on\" template", + "content_type": "Content type", "force_update": "Force update", "green_template": "Green template", + "image_encoding": "Image encoding", + "image_topic": "Image topic", "last_reset_value_template": "Last reset value template", "modes": "Supported operation modes", "mode_command_topic": "Operation mode command topic", @@ -348,6 +353,8 @@ "state_topic": "State topic", "state_value_template": "State value template", "supported_color_modes": "Supported color modes", + "url_template": "URL template", + "url_topic": "URL topic", "value_template": "Value template" }, "data_description": { @@ -363,8 +370,11 @@ "command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.", "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic. [Learn more.]({url}#command_template)", "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", + "content_type": "The content type or the image data that is received at the image topic.", "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", "green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.", + "image_encoding": "Select the encoding of the received image data", + "image_topic": "The MQTT topic subscribed to receive messages containing the image data. [Learn more.]({url}#image_topic)", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", "modes": "A list of supported operation modes. [Learn more.]({url}#modes)", "mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)", @@ -384,6 +394,8 @@ "state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.", "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", "supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)", + "url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)", + "url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)", "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value. [Learn more.]({url}#value_template)" }, "sections": { @@ -1261,6 +1273,12 @@ "diagnostic": "Diagnostic" } }, + "image_encoding": { + "options": { + "raw": "Raw data", + "b64": "Base64 encoding" + } + }, "image_processing_mode": { "options": { "image_data": "Image data is received", @@ -1289,6 +1307,7 @@ "climate": "[%key:component::climate::title%]", "cover": "[%key:component::cover::title%]", "fan": "[%key:component::fan::title%]", + "image": "[%key:component::image::title%]", "light": "[%key:component::light::title%]", "lock": "[%key:component::lock::title%]", "notify": "[%key:component::notify::title%]", diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 9c05fee8fd9d..af488fa613a9 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -356,6 +356,27 @@ MOCK_SUBENTRY_FAN_COMPONENT = { "speed_range_min": 1, }, } +MOCK_SUBENTRY_IMAGE_COMPONENT_DATA = { + "24402bcbd5b64a54bc32695a5ef752bf": { + "platform": "image", + "name": "Merchandise", + "entity_category": None, + "image_topic": "test-topic", + "content_type": "image/jpeg", + "image_encoding": "b64", + "entity_picture": "https://example.com/24402bcbd5b64a54bc32695a5ef752bf", + }, +} +MOCK_SUBENTRY_IMAGE_COMPONENT_URL = { + "326104eb58af48c9ab1f887cded499bb": { + "platform": "image", + "name": "Merchandise", + "entity_category": None, + "url_topic": "test-topic", + "url_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/326104eb58af48c9ab1f887cded499bb", + }, +} MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { "8131babc5e8d4f44b82e0761d39091a2": { "platform": "light", @@ -553,6 +574,14 @@ MOCK_FAN_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_FAN_COMPONENT, } +MOCK_IMAGE_SUBENTRY_DATA_IMAGE_DATA = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_IMAGE_COMPONENT_DATA, +} +MOCK_IMAGE_SUBENTRY_DATA_IMAGE_URL = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_IMAGE_COMPONENT_URL, +} MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index c56e0478c217..b361b0b595b1 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -43,6 +43,8 @@ from .common import ( MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, MOCK_COVER_SUBENTRY_DATA_SINGLE, MOCK_FAN_SUBENTRY_DATA_SINGLE, + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_DATA, + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_URL, MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, MOCK_LOCK_SUBENTRY_DATA_SINGLE, MOCK_NOTIFY_SUBENTRY_DATA_MULTI, @@ -3279,6 +3281,45 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Breezer", id="fan", ), + pytest.param( + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_DATA, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Merchandise"}, + {"image_processing_mode": "image_data"}, + (), + { + "image_topic": "test-topic", + "content_type": "image/jpeg", + "image_encoding": "b64", + }, + ( + ( + {"image_topic": "test-topic#invalid", "content_type": "image/jpeg"}, + {"image_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Merchandise", + id="notify_image_data", + ), + pytest.param( + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_URL, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Merchandise"}, + {"image_processing_mode": "image_url"}, + (), + { + "url_topic": "test-topic", + "url_template": "{{ value_json.value }}", + }, + ( + ( + {"url_topic": "test-topic#invalid"}, + {"url_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Merchandise", + id="notify_image_url", + ), pytest.param( MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 1}},