1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add event platform to HTML5 integration (#166577)

This commit is contained in:
Manu
2026-03-28 17:39:21 +01:00
committed by GitHub
parent b7bb185d50
commit 738b85c17d
10 changed files with 388 additions and 65 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,11 @@
{
"entity": {
"event": {
"event": {
"default": "mdi:gesture-tap-button"
}
}
},
"services": {
"dismiss": {
"service": "mdi:bell-off"

View File

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

View File

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

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.my_desktop',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'event.my_desktop',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

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

View File

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