Skip to content

Commit

Permalink
Add event entity (#96797)
Browse files Browse the repository at this point in the history
  • Loading branch information
frenck authored Jul 21, 2023
1 parent 4916351 commit 747f4d4
Show file tree
Hide file tree
Showing 18 changed files with 779 additions and 0 deletions.
1 change: 1 addition & 0 deletions .core_files.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Expand Down
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
Platform.COVER,
Platform.DATE,
Platform.DATETIME,
Platform.EVENT,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.LIGHT,
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/demo/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
47 changes: 47 additions & 0 deletions homeassistant/components/demo/event.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 11 additions & 0 deletions homeassistant/components/demo/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@
}
}
},
"event": {
"push": {
"state_attributes": {
"event_type": {
"state": {
"pressed": "Pressed"
}
}
}
}
},
"select": {
"speed": {
"state": {
Expand Down
209 changes: 209 additions & 0 deletions homeassistant/components/event/__init__.py
Original file line number Diff line number Diff line change
@@ -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())
5 changes: 5 additions & 0 deletions homeassistant/components/event/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Provides the constants needed for the component."""

DOMAIN = "event"
ATTR_EVENT_TYPE = "event_type"
ATTR_EVENT_TYPES = "event_types"
8 changes: 8 additions & 0 deletions homeassistant/components/event/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 12 additions & 0 deletions homeassistant/components/event/recorder.py
Original file line number Diff line number Diff line change
@@ -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}
25 changes: 25 additions & 0 deletions homeassistant/components/event/strings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
1 change: 1 addition & 0 deletions homeassistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/components/event/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The tests for the event integration."""
Loading

0 comments on commit 747f4d4

Please sign in to comment.