From 747f4d4a7328a2d0b4fd7083ac2d7cd45a21bdf7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 21 Jul 2023 12:16:35 +0200 Subject: [PATCH] Add event entity (#96797) --- .core_files.yaml | 1 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/button.py | 1 + homeassistant/components/demo/event.py | 47 +++ homeassistant/components/demo/strings.json | 11 + homeassistant/components/event/__init__.py | 209 +++++++++++ homeassistant/components/event/const.py | 5 + homeassistant/components/event/manifest.json | 8 + homeassistant/components/event/recorder.py | 12 + homeassistant/components/event/strings.json | 25 ++ homeassistant/const.py | 1 + mypy.ini | 10 + tests/components/event/__init__.py | 1 + tests/components/event/test_init.py | 352 ++++++++++++++++++ tests/components/event/test_recorder.py | 50 +++ .../custom_components/test/event.py | 42 +++ 18 files changed, 779 insertions(+) create mode 100644 homeassistant/components/demo/event.py create mode 100644 homeassistant/components/event/__init__.py create mode 100644 homeassistant/components/event/const.py create mode 100644 homeassistant/components/event/manifest.json create mode 100644 homeassistant/components/event/recorder.py create mode 100644 homeassistant/components/event/strings.json create mode 100644 tests/components/event/__init__.py create mode 100644 tests/components/event/test_init.py create mode 100644 tests/components/event/test_recorder.py create mode 100644 tests/testing_config/custom_components/test/event.py diff --git a/.core_files.yaml b/.core_files.yaml index b1870654be06f2..5e9b1d50defa27 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -24,6 +24,7 @@ base_platforms: &base_platforms - homeassistant/components/datetime/** - homeassistant/components/device_tracker/** - homeassistant/components/diagnostics/** + - homeassistant/components/event/** - homeassistant/components/fan/** - homeassistant/components/geo_location/** - homeassistant/components/humidifier/** diff --git a/.strict-typing b/.strict-typing index 67ebca7aea772f..9818e3d3197dfa 100644 --- a/.strict-typing +++ b/.strict-typing @@ -113,6 +113,7 @@ homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* homeassistant.components.energy.* homeassistant.components.esphome.* +homeassistant.components.event.* homeassistant.components.evil_genius_labs.* homeassistant.components.fan.* homeassistant.components.fastdotcom.* diff --git a/CODEOWNERS b/CODEOWNERS index 5198f12519c5c5..918ad4c23439a8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -358,6 +358,8 @@ build.json @home-assistant/supervisor /tests/components/esphome/ @OttoWinter @jesserockz @bdraco /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 +/homeassistant/components/event/ @home-assistant/core +/tests/components/event/ @home-assistant/core /homeassistant/components/evil_genius_labs/ @balloob /tests/components/evil_genius_labs/ @balloob /homeassistant/components/evohome/ @zxdavb diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 246c952e219417..6d54255f8ed59c 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -30,6 +30,7 @@ Platform.COVER, Platform.DATE, Platform.DATETIME, + Platform.EVENT, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py index 8b4a94a40afd85..3c0498fefefd51 100644 --- a/homeassistant/components/demo/button.py +++ b/homeassistant/components/demo/button.py @@ -51,3 +51,4 @@ async def async_press(self) -> None: persistent_notification.async_create( self.hass, "Button pressed", title="Button" ) + self.hass.bus.async_fire("demo_button_pressed") diff --git a/homeassistant/components/demo/event.py b/homeassistant/components/demo/event.py new file mode 100644 index 00000000000000..e9d26d9f54de45 --- /dev/null +++ b/homeassistant/components/demo/event.py @@ -0,0 +1,47 @@ +"""Demo platform that offers a fake event entity.""" +from __future__ import annotations + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo event platform.""" + async_add_entities([DemoEvent()]) + + +class DemoEvent(EventEntity): + """Representation of a demo event entity.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_event_types = ["pressed"] + _attr_has_entity_name = True + _attr_name = "Button press" + _attr_should_poll = False + _attr_translation_key = "push" + _attr_unique_id = "push" + + def __init__(self) -> None: + """Initialize the Demo event entity.""" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "push")}, + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.hass.bus.async_listen("demo_button_pressed", self._async_handle_event) + + @callback + def _async_handle_event(self, _: Event) -> None: + """Handle the demo button event.""" + self._trigger_event("pressed") + self.async_write_ha_state() diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index d9b896080722e0..555760a5af9546 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -46,6 +46,17 @@ } } }, + "event": { + "push": { + "state_attributes": { + "event_type": { + "state": { + "pressed": "Pressed" + } + } + } + } + }, "select": { "speed": { "state": { diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py new file mode 100644 index 00000000000000..6eeab6a32bbf09 --- /dev/null +++ b/homeassistant/components/event/__init__.py @@ -0,0 +1,209 @@ +"""Component for handling incoming events as a platform.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +import logging +from typing import Any, final + +from typing_extensions import Self + +from homeassistant.backports.enum import StrEnum +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + + +class EventDeviceClass(StrEnum): + """Device class for events.""" + + DOORBELL = "doorbell" + BUTTON = "button" + MOTION = "motion" + + +__all__ = [ + "ATTR_EVENT_TYPE", + "ATTR_EVENT_TYPES", + "DOMAIN", + "PLATFORM_SCHEMA_BASE", + "PLATFORM_SCHEMA", + "EventDeviceClass", + "EventEntity", + "EventEntityDescription", + "EventEntityFeature", +] + +# mypy: disallow-any-generics + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Event entities.""" + component = hass.data[DOMAIN] = EntityComponent[EventEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[EventEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[EventEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class EventEntityDescription(EntityDescription): + """A class that describes event entities.""" + + device_class: EventDeviceClass | None = None + event_types: list[str] | None = None + + +@dataclass +class EventExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + last_event_type: str | None + last_event_attributes: dict[str, Any] | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored event state from a dict.""" + try: + return cls( + restored["last_event_type"], + restored["last_event_attributes"], + ) + except KeyError: + return None + + +class EventEntity(RestoreEntity): + """Representation of a Event entity.""" + + entity_description: EventEntityDescription + _attr_device_class: EventDeviceClass | None + _attr_event_types: list[str] + _attr_state: None + + __last_event_triggered: datetime | None = None + __last_event_type: str | None = None + __last_event_attributes: dict[str, Any] | None = None + + @property + def device_class(self) -> EventDeviceClass | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + def event_types(self) -> list[str]: + """Return a list of possible events.""" + if hasattr(self, "_attr_event_types"): + return self._attr_event_types + if ( + hasattr(self, "entity_description") + and self.entity_description.event_types is not None + ): + return self.entity_description.event_types + raise AttributeError() + + @final + def _trigger_event( + self, event_type: str, event_attributes: dict[str, Any] | None = None + ) -> None: + """Process a new event.""" + if event_type not in self.event_types: + raise ValueError(f"Invalid event type {event_type} for {self.entity_id}") + self.__last_event_triggered = dt_util.utcnow() + self.__last_event_type = event_type + self.__last_event_attributes = event_attributes + + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For events this is True if the entity has a device class. + """ + return self.device_class is not None + + @property + @final + def capability_attributes(self) -> dict[str, list[str]]: + """Return capability attributes.""" + return { + ATTR_EVENT_TYPES: self.event_types, + } + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if (last_event := self.__last_event_triggered) is None: + return None + return last_event.isoformat(timespec="milliseconds") + + @final + @property + def state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + attributes = {ATTR_EVENT_TYPE: self.__last_event_type} + if self.__last_event_attributes: + attributes |= self.__last_event_attributes + return attributes + + @final + async def async_internal_added_to_hass(self) -> None: + """Call when the event entity is added to hass.""" + await super().async_internal_added_to_hass() + if ( + (state := await self.async_get_last_state()) + and state.state is not None + and (event_data := await self.async_get_last_event_data()) + ): + self.__last_event_triggered = dt_util.parse_datetime(state.state) + self.__last_event_type = event_data.last_event_type + self.__last_event_attributes = event_data.last_event_attributes + + @property + def extra_restore_state_data(self) -> EventExtraStoredData: + """Return event specific state data to be restored.""" + return EventExtraStoredData( + self.__last_event_type, + self.__last_event_attributes, + ) + + async def async_get_last_event_data(self) -> EventExtraStoredData | None: + """Restore event specific state date.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + return EventExtraStoredData.from_dict(restored_last_extra_data.as_dict()) diff --git a/homeassistant/components/event/const.py b/homeassistant/components/event/const.py new file mode 100644 index 00000000000000..cd6a8b96f7a3bd --- /dev/null +++ b/homeassistant/components/event/const.py @@ -0,0 +1,5 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "event" +ATTR_EVENT_TYPE = "event_type" +ATTR_EVENT_TYPES = "event_types" diff --git a/homeassistant/components/event/manifest.json b/homeassistant/components/event/manifest.json new file mode 100644 index 00000000000000..2da0940012a660 --- /dev/null +++ b/homeassistant/components/event/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "event", + "name": "Event", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/event", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/event/recorder.py b/homeassistant/components/event/recorder.py new file mode 100644 index 00000000000000..759fd80bcf0e0d --- /dev/null +++ b/homeassistant/components/event/recorder.py @@ -0,0 +1,12 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_EVENT_TYPES + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude static attributes from being recorded in the database.""" + return {ATTR_EVENT_TYPES} diff --git a/homeassistant/components/event/strings.json b/homeassistant/components/event/strings.json new file mode 100644 index 00000000000000..02f4da8ca0820d --- /dev/null +++ b/homeassistant/components/event/strings.json @@ -0,0 +1,25 @@ +{ + "title": "Event", + "entity_component": { + "_": { + "name": "[%key:component::button::title%]", + "state_attributes": { + "event_type": { + "name": "Event type" + }, + "event_types": { + "name": "Event types" + } + } + }, + "doorbell": { + "name": "Doorbell" + }, + "button": { + "name": "Button" + }, + "motion": { + "name": "Motion" + } + } +} diff --git a/homeassistant/const.py b/homeassistant/const.py index 5394e273a4c34c..94fa194fa09e37 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -34,6 +34,7 @@ class Platform(StrEnum): DATE = "date" DATETIME = "datetime" DEVICE_TRACKER = "device_tracker" + EVENT = "event" FAN = "fan" GEO_LOCATION = "geo_location" HUMIDIFIER = "humidifier" diff --git a/mypy.ini b/mypy.ini index ab8b5a5df8989d..4c2d803a549fbe 100644 --- a/mypy.ini +++ b/mypy.ini @@ -892,6 +892,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.event.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.evil_genius_labs.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/event/__init__.py b/tests/components/event/__init__.py new file mode 100644 index 00000000000000..e8236163d05227 --- /dev/null +++ b/tests/components/event/__init__.py @@ -0,0 +1 @@ +"""The tests for the event integration.""" diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py new file mode 100644 index 00000000000000..66cda6a088a3bb --- /dev/null +++ b/tests/components/event/test_init.py @@ -0,0 +1,352 @@ +"""The tests for the event integration.""" +from collections.abc import Generator +from typing import Any + +from freezegun import freeze_time +import pytest + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN, + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + async_mock_restore_state_shutdown_restart, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, + mock_restore_cache_with_extra_data, +) + +TEST_DOMAIN = "test" + + +async def test_event() -> None: + """Test the event entity.""" + event = EventEntity() + event.entity_id = "event.doorbell" + # Test event with no data at all + assert event.state is None + assert event.state_attributes == {ATTR_EVENT_TYPE: None} + assert not event.extra_state_attributes + assert event.device_class is None + + # No event types defined, should raise + with pytest.raises(AttributeError): + event.event_types + + # Test retrieving data from entity description + event.entity_description = EventEntityDescription( + key="test_event", + event_types=["short_press", "long_press"], + device_class=EventDeviceClass.DOORBELL, + ) + assert event.event_types == ["short_press", "long_press"] + assert event.device_class == EventDeviceClass.DOORBELL + + # Test attrs win over entity description + event._attr_event_types = ["short_press", "long_press", "double_press"] + assert event.event_types == ["short_press", "long_press", "double_press"] + event._attr_device_class = EventDeviceClass.BUTTON + assert event.device_class == EventDeviceClass.BUTTON + + # Test triggering an event + now = dt_util.utcnow() + with freeze_time(now): + event._trigger_event("long_press") + + assert event.state == now.isoformat(timespec="milliseconds") + assert event.state_attributes == {ATTR_EVENT_TYPE: "long_press"} + assert not event.extra_state_attributes + + # Test triggering an event, with extra attribute data + now = dt_util.utcnow() + with freeze_time(now): + event._trigger_event("short_press", {"hello": "world"}) + + assert event.state == now.isoformat(timespec="milliseconds") + assert event.state_attributes == { + ATTR_EVENT_TYPE: "short_press", + "hello": "world", + } + + # Test triggering an unknown event + with pytest.raises( + ValueError, match="^Invalid event type unknown_event for event.doorbell$" + ): + event._trigger_event("unknown_event") + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_restore_state(hass: HomeAssistant) -> None: + """Test we restore state integration.""" + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + "event.doorbell", + "2021-01-01T23:59:59.123+00:00", + attributes={ + ATTR_EVENT_TYPE: "ignored", + ATTR_EVENT_TYPES: [ + "single_press", + "double_press", + "do", + "not", + "restore", + ], + "hello": "worm", + }, + ), + { + "last_event_type": "double_press", + "last_event_attributes": { + "hello": "world", + }, + }, + ), + ), + ) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("event.doorbell") + assert state + assert state.state == "2021-01-01T23:59:59.123+00:00" + assert state.attributes[ATTR_EVENT_TYPES] == ["short_press", "long_press"] + assert state.attributes[ATTR_EVENT_TYPE] == "double_press" + assert state.attributes["hello"] == "world" + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_invalid_extra_restore_state(hass: HomeAssistant) -> None: + """Test we restore state integration.""" + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + "event.doorbell", + "2021-01-01T23:59:59.123+00:00", + ), + { + "invalid_unexpected_key": "double_press", + "last_event_attributes": { + "hello": "world", + }, + }, + ), + ), + ) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("event.doorbell") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_EVENT_TYPES] == ["short_press", "long_press"] + assert state.attributes[ATTR_EVENT_TYPE] is None + assert "hello" not in state.attributes + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_no_extra_restore_state(hass: HomeAssistant) -> None: + """Test we restore state integration.""" + mock_restore_cache( + hass, + ( + State( + "event.doorbell", + "2021-01-01T23:59:59.123+00:00", + attributes={ + ATTR_EVENT_TYPES: [ + "single_press", + "double_press", + ], + ATTR_EVENT_TYPE: "double_press", + "hello": "world", + }, + ), + ), + ) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("event.doorbell") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_EVENT_TYPES] == ["short_press", "long_press"] + assert state.attributes[ATTR_EVENT_TYPE] is None + assert "hello" not in state.attributes + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_saving_state(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: + """Test we restore state integration.""" + restore_data = {"last_event_type": "double_press", "last_event_attributes": None} + + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + "event.doorbell", + "2021-01-01T23:59:59.123+00:00", + ), + restore_data, + ), + ), + ) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == "event.doorbell" + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == restore_data + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.mark.usefixtures("config_flow_fixture") +async def test_name(hass: HomeAssistant) -> None: + """Test event name.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed event without device class -> no name + entity1 = EventEntity() + entity1._attr_event_types = ["ding"] + entity1.entity_id = "event.test1" + + # Unnamed event with device class but has_entity_name False -> no name + entity2 = EventEntity() + entity2._attr_event_types = ["ding"] + entity2.entity_id = "event.test2" + entity2._attr_device_class = EventDeviceClass.DOORBELL + + # Unnamed event with device class and has_entity_name True -> named + entity3 = EventEntity() + entity3._attr_event_types = ["ding"] + entity3.entity_id = "event.test3" + entity3._attr_device_class = EventDeviceClass.DOORBELL + entity3._attr_has_entity_name = True + + # Unnamed event with device class and has_entity_name True -> named + entity4 = EventEntity() + entity4._attr_event_types = ["ding"] + entity4.entity_id = "event.test4" + entity4.entity_description = EventEntityDescription( + "test", + EventDeviceClass.DOORBELL, + has_entity_name=True, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities([entity1, entity2, entity3, entity4]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity1.entity_id) + assert state + assert state.attributes == {"event_types": ["ding"], "event_type": None} + + state = hass.states.get(entity2.entity_id) + assert state + assert state.attributes == { + "event_types": ["ding"], + "event_type": None, + "device_class": "doorbell", + } + + state = hass.states.get(entity3.entity_id) + assert state + assert state.attributes == { + "event_types": ["ding"], + "event_type": None, + "device_class": "doorbell", + "friendly_name": "Doorbell", + } + + state = hass.states.get(entity4.entity_id) + assert state + assert state.attributes == { + "event_types": ["ding"], + "event_type": None, + "device_class": "doorbell", + "friendly_name": "Doorbell", + } diff --git a/tests/components/event/test_recorder.py b/tests/components/event/test_recorder.py new file mode 100644 index 00000000000000..133f7e173e3636 --- /dev/null +++ b/tests/components/event/test_recorder.py @@ -0,0 +1,50 @@ +"""The tests for event recorder.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from homeassistant.components import select +from homeassistant.components.event import ATTR_EVENT_TYPES +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.components.recorder.common import async_wait_recording_done + + +@pytest.fixture(autouse=True) +async def event_only() -> None: + """Enable only the event platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.EVENT], + ): + yield + + +async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test select registered attributes to be excluded.""" + now = dt_util.utcnow() + assert await async_setup_component(hass, "homeassistant", {}) + await async_setup_component( + hass, select.DOMAIN, {select.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + hass.bus.async_fire("demo_button_pressed") + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert state + assert ATTR_EVENT_TYPES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/testing_config/custom_components/test/event.py b/tests/testing_config/custom_components/test/event.py new file mode 100644 index 00000000000000..9acb24f37cffae --- /dev/null +++ b/tests/testing_config/custom_components/test/event.py @@ -0,0 +1,42 @@ +"""Provide a mock event platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.event import EventEntity + +from tests.common import MockEntity + +ENTITIES = [] + + +class MockEventEntity(MockEntity, EventEntity): + """Mock EventEntity class.""" + + @property + def event_types(self) -> list[str]: + """Return a list of possible events.""" + return self._handle("event_types") + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockEventEntity( + name="doorbell", + unique_id="unique_doorbell", + event_types=["short_press", "long_press"], + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES)