From 738b85c17d9d24bf6533bcc05e0dd2f1cb4fc383 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:39:21 +0100 Subject: [PATCH] Add event platform to HTML5 integration (#166577) --- homeassistant/components/html5/__init__.py | 2 +- homeassistant/components/html5/const.py | 6 + homeassistant/components/html5/entity.py | 73 +++++++++++ homeassistant/components/html5/event.py | 67 ++++++++++ homeassistant/components/html5/icons.json | 7 + homeassistant/components/html5/notify.py | 80 +++--------- homeassistant/components/html5/strings.json | 17 +++ .../html5/snapshots/test_event.ambr | 63 +++++++++ tests/components/html5/test_event.py | 120 ++++++++++++++++++ tests/components/html5/test_notify.py | 18 ++- 10 files changed, 388 insertions(+), 65 deletions(-) create mode 100644 homeassistant/components/html5/entity.py create mode 100644 homeassistant/components/html5/event.py create mode 100644 tests/components/html5/snapshots/test_event.ambr create mode 100644 tests/components/html5/test_event.py 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", {})