diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 758e978bb46..67fffec1106 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -255,6 +255,9 @@ ABBREVIATIONS = { "xy_cmd_t": "xy_command_topic", "xy_stat_t": "xy_state_topic", "xy_val_tpl": "xy_value_template", + "l_ver_t": "latest_version_topic", + "l_ver_tpl": "latest_version_template", + "pl_inst": "payload_install", } DEVICE_ABBREVIATIONS = { diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 2b07d6f9db4..2be125c2c12 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -32,6 +32,7 @@ from . import ( sensor as sensor_platform, siren as siren_platform, switch as switch_platform, + update as update_platform, vacuum as vacuum_platform, ) from .const import ( @@ -128,6 +129,9 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( Platform.SWITCH.value: vol.All( cv.ensure_list, [switch_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] ), + Platform.UPDATE.value: vol.All( + cv.ensure_list, [update_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), Platform.VACUUM.value: vol.All( cv.ensure_list, [vacuum_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] ), diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index ff9776a01f7..1dc25c1e78c 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -91,6 +91,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.UPDATE, Platform.VACUUM, ] @@ -111,5 +112,6 @@ RELOADABLE_PLATFORMS = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.UPDATE, Platform.VACUUM, ] diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 92ad50b7b4a..0aa288e700a 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -62,6 +62,7 @@ SUPPORTED_COMPONENTS = [ "sensor", "switch", "tag", + "update", "vacuum", ] diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py new file mode 100644 index 00000000000..ac8b5431a59 --- /dev/null +++ b/homeassistant/components/mqtt/update.py @@ -0,0 +1,199 @@ +"""Configure update platform in a device through MQTT topic.""" +from __future__ import annotations + +from collections.abc import Callable +import functools +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import update +from homeassistant.components.update import ( + DEVICE_CLASSES_SCHEMA, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import subscription +from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA +from .const import ( + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_QOS, + CONF_RETAIN, + CONF_STATE_TOPIC, +) +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .models import MqttValueTemplate, ReceiveMessage +from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "MQTT Update" + +CONF_LATEST_VERSION_TEMPLATE = "latest_version_template" +CONF_LATEST_VERSION_TOPIC = "latest_version_topic" +CONF_PAYLOAD_INSTALL = "payload_install" + + +PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_LATEST_VERSION_TEMPLATE): cv.template, + vol.Required(CONF_LATEST_VERSION_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_INSTALL): cv.string, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT update through configuration.yaml and dynamically through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, update.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, +) -> None: + """Set up the MQTT update.""" + async_add_entities([MqttUpdate(hass, config, config_entry, discovery_data)]) + + +class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): + """Representation of the MQTT update entity.""" + + _entity_id_format = update.ENTITY_ID_FORMAT + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, + ) -> None: + """Initialize the MQTT update.""" + self._config = config + self._sub_state = None + + self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + + UpdateEntity.__init__(self) + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._templates = { + CONF_VALUE_TEMPLATE: MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value, + CONF_LATEST_VERSION_TEMPLATE: MqttValueTemplate( + config.get(CONF_LATEST_VERSION_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_subscription( + topics: dict[str, Any], topic: str, msg_callback: Callable + ) -> None: + if self._config.get(topic) is not None: + topics[topic] = { + "topic": self._config[topic], + "msg_callback": msg_callback, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + + @callback + @log_messages(self.hass, self.entity_id) + def handle_installed_version_received(msg: ReceiveMessage) -> None: + """Handle receiving installed version via MQTT.""" + installed_version = self._templates[CONF_VALUE_TEMPLATE](msg.payload) + + if isinstance(installed_version, str) and installed_version != "": + self._attr_installed_version = installed_version + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscription(topics, CONF_STATE_TOPIC, handle_installed_version_received) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_latest_version_received(msg: ReceiveMessage) -> None: + """Handle receiving latest version via MQTT.""" + latest_version = self._templates[CONF_LATEST_VERSION_TEMPLATE](msg.payload) + + if isinstance(latest_version, str) and latest_version != "": + self._attr_latest_version = latest_version + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscription( + topics, CONF_LATEST_VERSION_TOPIC, handle_latest_version_received + ) + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Update the current value.""" + payload = self._config[CONF_PAYLOAD_INSTALL] + + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + support = 0 + + if self._config.get(CONF_COMMAND_TOPIC) is not None: + support |= UpdateEntityFeature.INSTALL + + return support diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py new file mode 100644 index 00000000000..9b008f093d0 --- /dev/null +++ b/tests/components/mqtt/test_update.py @@ -0,0 +1,393 @@ +"""The tests for mqtt update component.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, update +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.setup import async_setup_component + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setup_manual_entity_from_yaml, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + update.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "latest_version_topic": "test-topic", + "command_topic": "test-topic", + "payload_install": "install", + } + } +} + + +@pytest.fixture(autouse=True) +def update_platform_only(): + """Only setup the update platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.UPDATE]): + yield + + +async def test_run_update_setup(hass, mqtt_mock_entry_with_yaml_config): + """Test that it fetches the given payload.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": installed_version_topic, + "latest_version_topic": latest_version_topic, + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") + async_fire_mqtt_message(hass, latest_version_topic, "1.9.0") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + + async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_value_template(hass, mqtt_mock_entry_with_yaml_config): + """Test that it fetches the given payload with a template.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": installed_version_topic, + "value_template": "{{ value_json.installed }}", + "latest_version_topic": latest_version_topic, + "latest_version_template": "{{ value_json.latest }}", + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9.0"}') + async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + + async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"2.0.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_run_install_service(hass, mqtt_mock_entry_with_yaml_config): + """Test that install service works.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + command_topic = "test/install-command" + + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": installed_version_topic, + "latest_version_topic": latest_version_topic, + "command_topic": command_topic, + "payload_install": "install", + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") + async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_update"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with(command_topic, "install", 0, False) + + +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + update.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry_with_yaml_config, caplog +): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + update.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + update.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): + """Test unique id option only creates one update per unique_id.""" + config = { + mqtt.DOMAIN: { + update.DOMAIN: [ + { + "name": "Bear", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Milk", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, config + ) + + +async def test_discovery_removal_update(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test removal of discovered update.""" + data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][update.DOMAIN]) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, data + ) + + +async def test_discovery_update_update(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test update of discovered update.""" + config1 = { + "name": "Beer", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + } + config2 = { + "name": "Milk", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + } + + await help_test_discovery_update( + hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_update( + hass, mqtt_mock_entry_no_yaml_config, caplog +): + """Test update of discovered update.""" + data1 = '{ "name": "Beer", "state_topic": "installed-topic", "latest_version_topic": "latest-topic"}' + with patch( + "homeassistant.components.mqtt.update.MqttUpdate.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + update.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "state_topic": "installed-topic", "latest_version_topic": "latest-topic" }' + + await help_test_discovery_broken( + hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): + """Test MQTT update device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): + """Test MQTT update device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setup_manual_entity_from_yaml(hass): + """Test setup manual configured MQTT entity.""" + platform = update.DOMAIN + await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry(hass, mqtt_mock_entry_with_yaml_config, tmp_path): + """Test unloading the config entry.""" + domain = update.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + )