From 1b8e03bb6627ef1b9a23574cbfd65fdba1b56479 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 23 Jul 2023 14:42:14 +0200 Subject: [PATCH] Add MQTT event entity platform (#96876) Co-authored-by: Franck Nijhof --- homeassistant/components/mqtt/__init__.py | 4 +- .../components/mqtt/abbreviations.py | 1 + .../components/mqtt/config_integration.py | 5 + homeassistant/components/mqtt/const.py | 2 + homeassistant/components/mqtt/discovery.py | 1 + homeassistant/components/mqtt/event.py | 221 ++++++ tests/components/mqtt/test_event.py | 673 ++++++++++++++++++ 7 files changed, 905 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/mqtt/event.py create mode 100644 tests/components/mqtt/test_event.py diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 405eb86e6ec..9ec6447b32c 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import TemplateError, Unauthorized -from homeassistant.helpers import config_validation as cv, event, template +from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms @@ -340,7 +340,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unsub() await hass.async_add_executor_job(write_dump) - event.async_call_later(hass, call.data["duration"], finish_dump) + ev.async_call_later(hass, call.data["duration"], finish_dump) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index a5360090bb9..cc0f37ea145 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -60,6 +60,7 @@ ABBREVIATIONS = { "ent_pic": "entity_picture", "err_t": "error_topic", "err_tpl": "error_template", + "evt_typ": "event_types", "fanspd_t": "fan_speed_topic", "fanspd_tpl": "fan_speed_template", "fanspd_lst": "fan_speed_list", diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index ef2c771218a..cd4470ef22d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -22,6 +22,7 @@ from . import ( climate as climate_platform, cover as cover_platform, device_tracker as device_tracker_platform, + event as event_platform, fan as fan_platform, humidifier as humidifier_platform, image as image_platform, @@ -82,6 +83,10 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [device_tracker_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), + Platform.EVENT.value: vol.All( + cv.ensure_list, + [event_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] + ), Platform.FAN.value: vol.All( cv.ensure_list, [fan_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index d09a2bb8cb6..fb1989069af 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -112,6 +112,7 @@ PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.DEVICE_TRACKER, + Platform.EVENT, Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, @@ -138,6 +139,7 @@ RELOADABLE_PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, + Platform.EVENT, Platform.FAN, Platform.HUMIDIFIER, Platform.IMAGE, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 70e5ac9e535..8e563a48cdd 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -52,6 +52,7 @@ SUPPORTED_COMPONENTS = [ "cover", "device_automation", "device_tracker", + "event", "fan", "humidifier", "image", diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py new file mode 100644 index 00000000000..5a94ec754c0 --- /dev/null +++ b/homeassistant/components/mqtt/event.py @@ -0,0 +1,221 @@ +"""Support for MQTT events.""" +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 event +from homeassistant.components.event import ( + ENTITY_ID_FORMAT, + EventDeviceClass, + EventEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object + +from . import subscription +from .config import MQTT_RO_SCHEMA +from .const import ( + CONF_ENCODING, + CONF_QOS, + CONF_STATE_TOPIC, + PAYLOAD_EMPTY_JSON, + PAYLOAD_NONE, +) +from .debug_info import log_messages +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, +) +from .models import ( + MqttValueTemplate, + PayloadSentinel, + ReceiveMessage, + ReceivePayloadType, +) +from .util import get_mqtt_data + +_LOGGER = logging.getLogger(__name__) + +CONF_EVENT_TYPES = "event_types" + +MQTT_EVENT_ATTRIBUTES_BLOCKED = frozenset( + { + event.ATTR_EVENT_TYPE, + event.ATTR_EVENT_TYPES, + } +) + +DEFAULT_NAME = "MQTT Event" +DEFAULT_FORCE_UPDATE = False +DEVICE_CLASS_SCHEMA = vol.All(vol.Lower, vol.Coerce(EventDeviceClass)) + +_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASS_SCHEMA, + vol.Optional(CONF_NAME): vol.Any(None, cv.string), + vol.Required(CONF_EVENT_TYPES): vol.All(cv.ensure_list, [cv.string]), + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All( + _PLATFORM_SCHEMA_BASE, +) + +DISCOVERY_SCHEMA = vol.All( + _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT event through YAML and through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, event.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 MQTT event.""" + async_add_entities([MqttEvent(hass, config, config_entry, discovery_data)]) + + +class MqttEvent(MqttEntity, EventEntity): + """Representation of an event that can be updated using MQTT.""" + + _default_name = DEFAULT_NAME + _entity_id_format = ENTITY_ID_FORMAT + _attributes_extra_blocked = MQTT_EVENT_ATTRIBUTES_BLOCKED + _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the sensor.""" + 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._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_event_types = config[CONF_EVENT_TYPES] + self._template = MqttValueTemplate( + self._config.get(CONF_VALUE_TEMPLATE), entity=self + ).async_render_with_possible_json_value + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics: dict[str, dict[str, Any]] = {} + + @callback + @log_messages(self.hass, self.entity_id) + def message_received(msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + event_attributes: dict[str, Any] = {} + event_type: str + payload = self._template(msg.payload, PayloadSentinel.DEFAULT) + if ( + not payload + or payload is PayloadSentinel.DEFAULT + or payload == PAYLOAD_NONE + or payload == PAYLOAD_EMPTY_JSON + ): + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + try: + event_attributes = json_loads_object(payload) + event_type = str(event_attributes.pop(event.ATTR_EVENT_TYPE)) + _LOGGER.debug( + ( + "JSON event data detected after processing payload '%s' on" + " topic %s, type %s, attributes %s" + ), + payload, + msg.topic, + event_type, + event_attributes, + ) + except KeyError: + _LOGGER.warning( + ( + "`event_type` missing in JSON event payload, " + " '%s' on topic %s" + ), + payload, + msg.topic, + ) + return + except JSON_DECODE_EXCEPTIONS: + _LOGGER.warning( + ( + "No valid JSON event payload detected, " + "value after processing payload" + " '%s' on topic %s" + ), + payload, + msg.topic, + ) + return + try: + self._trigger_event(event_type, event_attributes) + except ValueError: + _LOGGER.warning( + "Invalid event type %s for %s received on topic %s, payload %s", + event_type, + self.entity_id, + msg.topic, + payload, + ) + return + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + topics["state_topic"] = { + "topic": self._config[CONF_STATE_TOPIC], + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + + 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) diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py new file mode 100644 index 00000000000..bc7b8b43523 --- /dev/null +++ b/tests/components/mqtt/test_event.py @@ -0,0 +1,673 @@ +"""The tests for the MQTT event platform.""" +import copy +import json +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components import event, mqtt +from homeassistant.components.mqtt.event import MQTT_EVENT_ATTRIBUTES_BLOCKED +from homeassistant.const import ( + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_list_payload, + help_test_default_availability_list_payload_all, + help_test_default_availability_list_payload_any, + help_test_default_availability_list_single, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update_attr, + help_test_discovery_update_availability, + help_test_entity_category, + help_test_entity_debug_info, + help_test_entity_debug_info_message, + help_test_entity_debug_info_remove, + help_test_entity_debug_info_update_entity_id, + 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_disabled_by_default, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_entity_name, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + 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, +) +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + event.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "event_types": ["press"], + } + } +} + + +@pytest.fixture(autouse=True) +def event_platform_only(): + """Only setup the event platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.EVENT]): + yield + + +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setting_event_value_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the an MQTT event with attributes.""" + await mqtt_mock_entry() + + async_fire_mqtt_message( + hass, "test-topic", '{"event_type": "press", "duration": "short" }' + ) + state = hass.states.get("event.test") + + assert state.state == "2023-08-01T00:00:00.000+00:00" + assert state.attributes.get("duration") == "short" + + +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + ("message", "log"), + [ + ( + '{"event_type": "press", "duration": "short" ', + "No valid JSON event payload detected", + ), + ('{"event_type": "invalid", "duration": "short" }', "Invalid event type"), + ('{"event_type": 2, "duration": "short" }', "Invalid event type"), + ('{"event_type": null, "duration": "short" }', "Invalid event type"), + ( + '{"event": "press", "duration": "short" }', + "`event_type` missing in JSON event payload", + ), + ], +) +async def test_setting_event_value_with_invalid_payload( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + message: str, + log: str, +) -> None: + """Test the an MQTT event with attributes.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", message) + state = hass.states.get("event.test") + + assert state is not None + assert state.state == STATE_UNKNOWN + assert log in caplog.text + + +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + event.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "event_types": ["press"], + "value_template": '{"event_type": "press", "val": "{{ value_json.val | is_defined }}", "par": "{{ value_json.par }}"}', + } + } + } + ], +) +async def test_setting_event_value_via_mqtt_json_message_and_default_current_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test processing an event via MQTT with fall back to current state.""" + await mqtt_mock_entry() + + async_fire_mqtt_message( + hass, "test-topic", '{ "val": "valcontent", "par": "parcontent" }' + ) + state = hass.states.get("event.test") + + assert state.state == "2023-08-01T00:00:00.000+00:00" + assert state.attributes.get("val") == "valcontent" + assert state.attributes.get("par") == "parcontent" + + freezer.move_to("2023-08-01 00:00:10+00:00") + + async_fire_mqtt_message(hass, "test-topic", '{ "par": "invalidcontent" }') + state = hass.states.get("event.test") + + assert state.state == "2023-08-01T00:00:00.000+00:00" + assert state.attributes.get("val") == "valcontent" + assert state.attributes.get("par") == "parcontent" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, event.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_list_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_list_payload( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_list_payload_all( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_list_payload_all( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_list_payload_any( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_list_payload_any( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_list_single( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability list and availability_topic are mutually exclusive.""" + await help_test_default_availability_list_single( + hass, caplog, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_update_availability( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability discovery update.""" + await help_test_discovery_update_availability( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + event.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "event_types": ["press"], + "device_class": "foobarnotreal", + } + } + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry() + assert ( + "Invalid config for [mqtt]: expected EventDeviceClass or one of" in caplog.text + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, + mqtt_mock_entry, + event.DOMAIN, + DEFAULT_CONFIG, + MQTT_EVENT_ATTRIBUTES_BLOCKED, + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + event.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + event.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + event.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + event.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "event_types": ["press"], + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "event_types": ["press"], + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one event per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, event.DOMAIN) + + +async def test_discovery_removal_event( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered event.""" + data = '{ "name": "test", "state_topic": "test_topic", "event_types": ["press"] }' + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, event.DOMAIN, data) + + +async def test_discovery_update_event_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered mqtt event template.""" + await mqtt_mock_entry() + config = {"name": "test", "state_topic": "test_topic", "event_types": ["press"]} + config1 = copy.deepcopy(config) + config2 = copy.deepcopy(config) + config1["name"] = "Beer" + config2["name"] = "Milk" + config1["state_topic"] = "event/state1" + config2["state_topic"] = "event/state1" + config1[ + "value_template" + ] = '{"event_type": "press", "val": "{{ value_json.val | int }}"}' + config2[ + "value_template" + ] = '{"event_type": "press", "val": "{{ value_json.val | int * 2 }}"}' + + async_fire_mqtt_message(hass, "homeassistant/event/bla/config", json.dumps(config1)) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "event/state1", '{"val":100}') + await hass.async_block_till_done() + state = hass.states.get("event.beer") + assert state is not None + assert state.attributes.get("val") == "100" + + async_fire_mqtt_message(hass, "homeassistant/event/bla/config", json.dumps(config2)) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "event/state1", '{"val":100}') + await hass.async_block_till_done() + state = hass.states.get("event.beer") + assert state is not None + assert state.attributes.get("val") == "200" + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer", "state_topic": "test_topic#", "event_types": ["press"] }' + data2 = '{ "name": "Milk", "state_topic": "test_topic", "event_types": ["press"] }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, event.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_hub( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event device registry integration.""" + await mqtt_mock_entry() + registry = dr.async_get(hass) + hub = registry.async_get_or_create( + config_entry_id="123", + connections=set(), + identifiers={("mqtt", "hub-id")}, + manufacturer="manufacturer", + model="hub", + ) + + data = json.dumps( + { + "name": "Test 1", + "state_topic": "test-topic", + "event_types": ["press"], + "device": {"identifiers": ["helloworld"], "via_device": "hub-id"}, + "unique_id": "veryunique", + } + ) + async_fire_mqtt_message(hass, "homeassistant/event/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + assert device is not None + assert device.via_device_id == hub.id + + +async def test_entity_debug_info( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event debug info.""" + await help_test_entity_debug_info( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_entity_debug_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event debug info.""" + await help_test_entity_debug_info_remove( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_update_entity_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event debug info.""" + await help_test_entity_debug_info_update_entity_id( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_disabled_by_default( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test entity disabled by default.""" + await help_test_entity_disabled_by_default( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_entity_category( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test entity category.""" + await help_test_entity_category(hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + event.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "event_types": ["press"], + "value_template": '{ "event_type": "press", "val": \ + {% if state_attr(entity_id, "friendly_name") == "test" %} \ + "{{ value | int + 1 }}" \ + {% else %} \ + "{{ value }}" \ + {% endif %}}', + } + } + } + ], +) +async def test_value_template_with_entity_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the access to attributes in value_template via the entity_id.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", "100") + state = hass.states.get("event.test") + + assert state.attributes.get("val") == "101" + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = event.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = event.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = event.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + ("expected_friendly_name", "device_class"), + [("test", None), ("Doorbell", "doorbell"), ("Motion", "motion")], +) +async def test_entity_name( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_friendly_name: str | None, + device_class: str | None, +) -> None: + """Test the entity name setup.""" + domain = event.DOMAIN + config = DEFAULT_CONFIG + await help_test_entity_name( + hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class + )