Add event entity (#96797)

This commit is contained in:
Franck Nijhof 2023-07-21 12:16:35 +02:00 committed by GitHub
parent 4916351d9a
commit 747f4d4a73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 779 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
Platform.COVER,
Platform.DATE,
Platform.DATETIME,
Platform.EVENT,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.LIGHT,

View File

@ -51,3 +51,4 @@ class DemoButton(ButtonEntity):
persistent_notification.async_create(
self.hass, "Button pressed", title="Button"
)
self.hass.bus.async_fire("demo_button_pressed")

View File

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

View File

@ -46,6 +46,17 @@
}
}
},
"event": {
"push": {
"state_attributes": {
"event_type": {
"state": {
"pressed": "Pressed"
}
}
}
}
},
"select": {
"speed": {
"state": {

View File

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

View File

@ -0,0 +1,5 @@
"""Provides the constants needed for the component."""
DOMAIN = "event"
ATTR_EVENT_TYPE = "event_type"
ATTR_EVENT_TYPES = "event_types"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
"""The tests for the event integration."""

View File

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

View File

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

View File

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