diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py
index ed980a32cee..225379dfa1a 100644
--- a/homeassistant/components/html5/__init__.py
+++ b/homeassistant/components/html5/__init__.py
@@ -9,7 +9,7 @@ from .const import DOMAIN
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-PLATFORMS = [Platform.NOTIFY]
+PLATFORMS = [Platform.EVENT, Platform.NOTIFY]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/html5/const.py b/homeassistant/components/html5/const.py
index 75826ab90c9..a256241b066 100644
--- a/homeassistant/components/html5/const.py
+++ b/homeassistant/components/html5/const.py
@@ -7,3 +7,9 @@ SERVICE_DISMISS = "dismiss"
ATTR_VAPID_PUB_KEY = "vapid_pub_key"
ATTR_VAPID_PRV_KEY = "vapid_prv_key"
ATTR_VAPID_EMAIL = "vapid_email"
+
+REGISTRATIONS_FILE = "html5_push_registrations.conf"
+
+ATTR_ACTION = "action"
+ATTR_DATA = "data"
+ATTR_TAG = "tag"
diff --git a/homeassistant/components/html5/entity.py b/homeassistant/components/html5/entity.py
new file mode 100644
index 00000000000..71b85208271
--- /dev/null
+++ b/homeassistant/components/html5/entity.py
@@ -0,0 +1,73 @@
+"""Base entities for HTML5 integration."""
+
+from __future__ import annotations
+
+from typing import NotRequired, TypedDict
+
+from aiohttp import ClientSession
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN
+
+
+class Keys(TypedDict):
+ """Types for keys."""
+
+ p256dh: str
+ auth: str
+
+
+class Subscription(TypedDict):
+ """Types for subscription."""
+
+ endpoint: str
+ expirationTime: int | None
+ keys: Keys
+
+
+class Registration(TypedDict):
+ """Types for registration."""
+
+ subscription: Subscription
+ browser: str
+ name: NotRequired[str]
+
+
+class HTML5Entity(Entity):
+ """Base entity for HTML5 integration."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+ _key: str
+
+ def __init__(
+ self,
+ config_entry: ConfigEntry,
+ target: str,
+ registrations: dict[str, Registration],
+ session: ClientSession,
+ json_path: str,
+ ) -> None:
+ """Initialize the entity."""
+ self.config_entry = config_entry
+ self.target = target
+ self.registrations = registrations
+ self.registration = registrations[target]
+ self.session = session
+ self.json_path = json_path
+
+ self._attr_unique_id = f"{config_entry.entry_id}_{target}_{self._key}"
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ name=target,
+ model=self.registration["browser"].capitalize(),
+ identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")},
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available and self.target in self.registrations
diff --git a/homeassistant/components/html5/event.py b/homeassistant/components/html5/event.py
new file mode 100644
index 00000000000..6f74d61d83d
--- /dev/null
+++ b/homeassistant/components/html5/event.py
@@ -0,0 +1,67 @@
+"""Event platform for HTML5 integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components.event import EventEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import ATTR_ACTION, ATTR_DATA, ATTR_TAG, DOMAIN, REGISTRATIONS_FILE
+from .entity import HTML5Entity
+from .notify import _load_config
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the event entity platform."""
+
+ json_path = hass.config.path(REGISTRATIONS_FILE)
+ registrations = await hass.async_add_executor_job(_load_config, json_path)
+
+ session = async_get_clientsession(hass)
+ async_add_entities(
+ HTML5EventEntity(config_entry, target, registrations, session, json_path)
+ for target in registrations
+ )
+
+
+class HTML5EventEntity(HTML5Entity, EventEntity):
+ """Representation of an event entity."""
+
+ _key = "event"
+ _attr_event_types = ["clicked", "received", "closed"]
+ _attr_translation_key = "event"
+
+ @callback
+ def _async_handle_event(
+ self, target: str, event_type: str, event_data: dict[str, Any]
+ ) -> None:
+ """Handle the event."""
+
+ if target == self.target:
+ self._trigger_event(
+ event_type,
+ {
+ **event_data.get(ATTR_DATA, {}),
+ ATTR_ACTION: event_data.get(ATTR_ACTION),
+ ATTR_TAG: event_data.get(ATTR_TAG),
+ },
+ )
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Register event callback."""
+
+ self.async_on_remove(
+ async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event)
+ )
diff --git a/homeassistant/components/html5/icons.json b/homeassistant/components/html5/icons.json
index d0a6013dd12..4b3fd84b69f 100644
--- a/homeassistant/components/html5/icons.json
+++ b/homeassistant/components/html5/icons.json
@@ -1,4 +1,11 @@
{
+ "entity": {
+ "event": {
+ "event": {
+ "default": "mdi:gesture-tap-button"
+ }
+ }
+ },
"services": {
"dismiss": {
"service": "mdi:bell-off"
diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py
index a5e823ce629..21d57f7fb8d 100644
--- a/homeassistant/components/html5/notify.py
+++ b/homeassistant/components/html5/notify.py
@@ -8,7 +8,7 @@ from http import HTTPStatus
import json
import logging
import time
-from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast
+from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
import uuid
@@ -38,7 +38,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.json import save_json
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -46,17 +46,19 @@ from homeassistant.util import ensure_unique_string
from homeassistant.util.json import load_json_object
from .const import (
+ ATTR_ACTION,
+ ATTR_TAG,
ATTR_VAPID_EMAIL,
ATTR_VAPID_PRV_KEY,
ATTR_VAPID_PUB_KEY,
DOMAIN,
+ REGISTRATIONS_FILE,
SERVICE_DISMISS,
)
+from .entity import HTML5Entity, Registration
_LOGGER = logging.getLogger(__name__)
-REGISTRATIONS_FILE = "html5_push_registrations.conf"
-
ATTR_SUBSCRIPTION = "subscription"
ATTR_BROWSER = "browser"
@@ -67,8 +69,6 @@ ATTR_AUTH = "auth"
ATTR_P256DH = "p256dh"
ATTR_EXPIRATIONTIME = "expirationTime"
-ATTR_TAG = "tag"
-ATTR_ACTION = "action"
ATTR_ACTIONS = "actions"
ATTR_TYPE = "type"
ATTR_URL = "url"
@@ -156,29 +156,6 @@ HTML5_SHOWNOTIFICATION_PARAMETERS = (
)
-class Keys(TypedDict):
- """Types for keys."""
-
- p256dh: str
- auth: str
-
-
-class Subscription(TypedDict):
- """Types for subscription."""
-
- endpoint: str
- expirationTime: int | None
- keys: Keys
-
-
-class Registration(TypedDict):
- """Types for registration."""
-
- subscription: Subscription
- browser: str
- name: NotRequired[str]
-
-
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
@@ -419,7 +396,15 @@ class HTML5PushCallbackView(HomeAssistantView):
)
event_name = f"{NOTIFY_CALLBACK_EVENT}.{event_payload[ATTR_TYPE]}"
- request.app[KEY_HASS].bus.fire(event_name, event_payload)
+ hass = request.app[KEY_HASS]
+ hass.bus.fire(event_name, event_payload)
+ async_dispatcher_send(
+ hass,
+ DOMAIN,
+ event_payload[ATTR_TARGET],
+ event_payload[ATTR_TYPE],
+ event_payload,
+ )
return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]})
@@ -613,37 +598,11 @@ async def async_setup_entry(
)
-class HTML5NotifyEntity(NotifyEntity):
+class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
"""Representation of a notification entity."""
- _attr_has_entity_name = True
- _attr_name = None
-
_attr_supported_features = NotifyEntityFeature.TITLE
-
- def __init__(
- self,
- config_entry: ConfigEntry,
- target: str,
- registrations: dict[str, Registration],
- session: ClientSession,
- json_path: str,
- ) -> None:
- """Initialize the entity."""
- self.config_entry = config_entry
- self.target = target
- self.registrations = registrations
- self.registration = registrations[target]
- self.session = session
- self.json_path = json_path
-
- self._attr_unique_id = f"{config_entry.entry_id}_{target}_device"
- self._attr_device_info = DeviceInfo(
- entry_type=DeviceEntryType.SERVICE,
- name=target,
- model=self.registration["browser"].capitalize(),
- identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")},
- )
+ _key = "device"
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to a device."""
@@ -714,8 +673,3 @@ class HTML5NotifyEntity(NotifyEntity):
translation_key="connection_error",
translation_placeholders={"target": self.target},
) from e
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return super().available and self.target in self.registrations
diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json
index 81964a2af95..e786f80a456 100644
--- a/homeassistant/components/html5/strings.json
+++ b/homeassistant/components/html5/strings.json
@@ -20,6 +20,23 @@
}
}
},
+ "entity": {
+ "event": {
+ "event": {
+ "state_attributes": {
+ "action": { "name": "Action" },
+ "event_type": {
+ "state": {
+ "clicked": "Clicked",
+ "closed": "Closed",
+ "received": "Received"
+ }
+ },
+ "tag": { "name": "Tag" }
+ }
+ }
+ }
+ },
"exceptions": {
"channel_expired": {
"message": "Notification channel for {target} has expired"
diff --git a/tests/components/html5/snapshots/test_event.ambr b/tests/components/html5/snapshots/test_event.ambr
new file mode 100644
index 00000000000..3db7160da7f
--- /dev/null
+++ b/tests/components/html5/snapshots/test_event.ambr
@@ -0,0 +1,63 @@
+# serializer version: 1
+# name: test_setup[event.my_desktop-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'event_types': list([
+ 'clicked',
+ 'received',
+ 'closed',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'event',
+ 'entity_category': None,
+ 'entity_id': 'event.my_desktop',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'html5',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'event',
+ 'unique_id': 'ABCDEFGHIJKLMNOPQRSTUVWXYZ_my-desktop_event',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_setup[event.my_desktop-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'event_type': None,
+ 'event_types': list([
+ 'clicked',
+ 'received',
+ 'closed',
+ ]),
+ 'friendly_name': 'my-desktop',
+ }),
+ 'context': ,
+ 'entity_id': 'event.my_desktop',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
diff --git a/tests/components/html5/test_event.py b/tests/components/html5/test_event.py
new file mode 100644
index 00000000000..cd4be641f35
--- /dev/null
+++ b/tests/components/html5/test_event.py
@@ -0,0 +1,120 @@
+"""Tests for the HTML5 event platform."""
+
+from collections.abc import Generator
+from http import HTTPStatus
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+from aiohttp.hdrs import AUTHORIZATION
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.html5.notify import ATTR_ACTION, ATTR_TAG, ATTR_TYPE
+from homeassistant.components.notify import ATTR_DATA, ATTR_TARGET
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import STATE_UNKNOWN, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+from homeassistant.setup import async_setup_component
+
+from .test_notify import SUBSCRIPTION_1
+
+from tests.common import MockConfigEntry, snapshot_platform
+from tests.typing import ClientSessionGenerator
+
+
+@pytest.fixture
+def event_only() -> Generator[None]:
+ """Enable only the event platform."""
+ with patch(
+ "homeassistant.components.html5.PLATFORMS",
+ [Platform.EVENT],
+ ):
+ yield
+
+
+@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid", "event_only")
+@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z")
+async def test_setup(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+ load_config: MagicMock,
+) -> None:
+ """Snapshot test states of event platform."""
+ load_config.return_value = {"my-desktop": SUBSCRIPTION_1}
+
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state is ConfigEntryState.LOADED
+
+ await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
+
+
+@pytest.mark.parametrize(
+ ("event_payload"),
+ [
+ {
+ ATTR_TARGET: "my-desktop",
+ ATTR_TYPE: "clicked",
+ ATTR_ACTION: "open_app",
+ ATTR_TAG: "1234",
+ ATTR_DATA: {"customKey": "Value"},
+ },
+ {
+ ATTR_TARGET: "my-desktop",
+ ATTR_TYPE: "received",
+ ATTR_TAG: "1234",
+ ATTR_DATA: {"customKey": "Value"},
+ },
+ {
+ ATTR_TARGET: "my-desktop",
+ ATTR_TYPE: "closed",
+ ATTR_TAG: "1234",
+ ATTR_DATA: {"customKey": "Value"},
+ },
+ ],
+)
+@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
+@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z")
+async def test_events(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ load_config: MagicMock,
+ event_payload: dict[str, Any],
+ hass_client: ClientSessionGenerator,
+ mock_jwt: MagicMock,
+) -> None:
+ """Test events."""
+ load_config.return_value = {"my-desktop": SUBSCRIPTION_1}
+ await async_setup_component(hass, "http", {})
+
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state is ConfigEntryState.LOADED
+
+ assert (state := hass.states.get("event.my_desktop")) is not None
+ assert state.state == STATE_UNKNOWN
+
+ client = await hass_client()
+
+ mock_jwt.decode.return_value = {ATTR_TARGET: event_payload[ATTR_TARGET]}
+
+ resp = await client.post(
+ "/api/notify.html5/callback",
+ json=event_payload,
+ headers={AUTHORIZATION: "Bearer JWT"},
+ )
+
+ assert resp.status == HTTPStatus.OK
+
+ assert (state := hass.states.get("event.my_desktop"))
+ assert state.state == "1970-01-01T00:00:00.000+00:00"
+ assert state.attributes.get("action") == event_payload.get(ATTR_ACTION)
+ assert state.attributes.get("tag") == event_payload[ATTR_TAG]
+ assert state.attributes.get("customKey") == event_payload[ATTR_DATA]["customKey"]
diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py
index 7b6d3113b47..d7a83b2f66e 100644
--- a/tests/components/html5/test_notify.py
+++ b/tests/components/html5/test_notify.py
@@ -1,5 +1,6 @@
"""Test HTML5 notify platform."""
+from collections.abc import Generator
from http import HTTPStatus
import json
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
@@ -18,7 +19,12 @@ from homeassistant.components.notify import (
SERVICE_SEND_MESSAGE,
)
from homeassistant.config_entries import ConfigEntryState
-from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+ Platform,
+)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@@ -89,6 +95,16 @@ VAPID_HEADERS = {
}
+@pytest.fixture(autouse=True)
+def notify_only() -> Generator[None]:
+ """Enable only the notify platform."""
+ with patch(
+ "homeassistant.components.html5.PLATFORMS",
+ [Platform.NOTIFY],
+ ):
+ yield
+
+
async def test_get_service_with_no_json(hass: HomeAssistant) -> None:
"""Test empty json file."""
await async_setup_component(hass, "http", {})