From 52af74c3b6cb61d6c94e17d1517ad04955c3ec98 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:49:59 +0200 Subject: [PATCH] Add entity action `html5.send_message` to HTML5 integration (#166349) Co-authored-by: Joostlek --- homeassistant/components/html5/__init__.py | 9 + homeassistant/components/html5/const.py | 13 ++ homeassistant/components/html5/icons.json | 3 + homeassistant/components/html5/issue.py | 31 +++ homeassistant/components/html5/notify.py | 73 ++++-- homeassistant/components/html5/services.py | 82 +++++++ homeassistant/components/html5/services.yaml | 134 +++++++++++ homeassistant/components/html5/strings.json | 112 ++++++++++ tests/components/html5/test_notify.py | 222 +++++++++++++++++-- 9 files changed, 641 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/html5/issue.py create mode 100644 homeassistant/components/html5/services.py diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py index 225379dfa1a..5cd10a98a27 100644 --- a/homeassistant/components/html5/__init__.py +++ b/homeassistant/components/html5/__init__.py @@ -4,14 +4,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.EVENT, Platform.NOTIFY] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the HTML5 services.""" + + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up HTML5 from a config entry.""" hass.async_create_task( diff --git a/homeassistant/components/html5/const.py b/homeassistant/components/html5/const.py index a256241b066..cc0aeb282c7 100644 --- a/homeassistant/components/html5/const.py +++ b/homeassistant/components/html5/const.py @@ -11,5 +11,18 @@ ATTR_VAPID_EMAIL = "vapid_email" REGISTRATIONS_FILE = "html5_push_registrations.conf" ATTR_ACTION = "action" +ATTR_ACTIONS = "actions" +ATTR_BADGE = "badge" ATTR_DATA = "data" +ATTR_DIR = "dir" +ATTR_ICON = "icon" +ATTR_IMAGE = "image" +ATTR_LANG = "lang" +ATTR_RENOTIFY = "renotify" +ATTR_REQUIRE_INTERACTION = "require_interaction" +ATTR_SILENT = "silent" ATTR_TAG = "tag" +ATTR_TIMESTAMP = "timestamp" +ATTR_TTL = "ttl" +ATTR_URGENCY = "urgency" +ATTR_VIBRATE = "vibrate" diff --git a/homeassistant/components/html5/icons.json b/homeassistant/components/html5/icons.json index 4b3fd84b69f..e4b738f22b3 100644 --- a/homeassistant/components/html5/icons.json +++ b/homeassistant/components/html5/icons.json @@ -9,6 +9,9 @@ "services": { "dismiss": { "service": "mdi:bell-off" + }, + "send_message": { + "service": "mdi:message-arrow-right" } } } diff --git a/homeassistant/components/html5/issue.py b/homeassistant/components/html5/issue.py new file mode 100644 index 00000000000..a12c5e9217d --- /dev/null +++ b/homeassistant/components/html5/issue.py @@ -0,0 +1,31 @@ +"""Issues for HTML5 integration.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.util import slugify + +from .const import DOMAIN + + +@callback +def deprecated_notify_action_call( + hass: HomeAssistant, target: list[str] | None +) -> None: + """Deprecated action call.""" + + action = ( + f"notify.html5_{slugify(target[0])}" + if target and len(target) == 1 + else "notify.html5" + ) + + async_create_issue( + hass, + DOMAIN, + f"deprecated_notify_action_{action}", + breaks_in_ha_version="2026.11.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_notify_action", + translation_placeholders={"action": action}, + ) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 21d57f7fb8d..5d7989a129e 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -47,7 +47,11 @@ from homeassistant.util.json import load_json_object from .const import ( ATTR_ACTION, + ATTR_ACTIONS, + ATTR_REQUIRE_INTERACTION, ATTR_TAG, + ATTR_TIMESTAMP, + ATTR_TTL, ATTR_VAPID_EMAIL, ATTR_VAPID_PRV_KEY, ATTR_VAPID_PUB_KEY, @@ -56,6 +60,7 @@ from .const import ( SERVICE_DISMISS, ) from .entity import HTML5Entity, Registration +from .issue import deprecated_notify_action_call _LOGGER = logging.getLogger(__name__) @@ -69,13 +74,11 @@ ATTR_AUTH = "auth" ATTR_P256DH = "p256dh" ATTR_EXPIRATIONTIME = "expirationTime" -ATTR_ACTIONS = "actions" ATTR_TYPE = "type" ATTR_URL = "url" ATTR_DISMISS = "dismiss" ATTR_PRIORITY = "priority" DEFAULT_PRIORITY = "normal" -ATTR_TTL = "ttl" DEFAULT_TTL = 86400 DEFAULT_BADGE = "/static/images/notification-badge.png" @@ -465,6 +468,9 @@ class HTML5NotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" + + deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET)) + tag = str(uuid.uuid4()) payload: dict[str, Any] = { "badge": DEFAULT_BADGE, @@ -605,32 +611,53 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity): _key = "device" async def async_send_message(self, message: str, title: str | None = None) -> None: - """Send a message to a device.""" - timestamp = int(time.time()) - tag = str(uuid.uuid4()) + """Send a message to a device via notify.send_message action.""" + await self._webpush( + title=title or ATTR_TITLE_DEFAULT, + message=message, + badge=DEFAULT_BADGE, + icon=DEFAULT_ICON, + ) - payload: dict[str, Any] = { - "badge": DEFAULT_BADGE, - "body": message, - "icon": DEFAULT_ICON, - ATTR_TAG: tag, - ATTR_TITLE: title or ATTR_TITLE_DEFAULT, - "timestamp": timestamp * 1000, - ATTR_DATA: { - ATTR_JWT: add_jwt( - timestamp, - self.target, - tag, - self.registration["subscription"]["keys"]["auth"], - ) - }, - } + async def send_push_notification(self, **kwargs: Any) -> None: + """Send a message to a device via html5.send_message action.""" + await self._webpush(**kwargs) + self._async_record_notification() + + async def _webpush( + self, + message: str | None = None, + timestamp: datetime | None = None, + ttl: timedelta | None = None, + urgency: str | None = None, + **kwargs: Any, + ) -> None: + """Shared internal helper to push messages.""" + payload: dict[str, Any] = kwargs + + if message is not None: + payload["body"] = message + + payload.setdefault(ATTR_TAG, str(uuid.uuid4())) + ts = int(timestamp.timestamp()) if timestamp else int(time.time()) + payload[ATTR_TIMESTAMP] = ts * 1000 + + if ATTR_REQUIRE_INTERACTION in payload: + payload["requireInteraction"] = payload.pop(ATTR_REQUIRE_INTERACTION) + + payload.setdefault(ATTR_DATA, {}) + payload[ATTR_DATA][ATTR_JWT] = add_jwt( + ts, + self.target, + payload[ATTR_TAG], + self.registration["subscription"]["keys"]["auth"], + ) endpoint = urlparse(self.registration["subscription"]["endpoint"]) vapid_claims = { "sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}", "aud": f"{endpoint.scheme}://{endpoint.netloc}", - "exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60), + "exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60), } try: @@ -639,6 +666,8 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity): json.dumps(payload), self.config_entry.data[ATTR_VAPID_PRV_KEY], vapid_claims, + ttl=int(ttl.total_seconds()) if ttl is not None else DEFAULT_TTL, + headers={"Urgency": urgency} if urgency else None, aiohttp_session=self.session, ) cast(ClientResponse, response).raise_for_status() diff --git a/homeassistant/components/html5/services.py b/homeassistant/components/html5/services.py new file mode 100644 index 00000000000..40a2e1c311c --- /dev/null +++ b/homeassistant/components/html5/services.py @@ -0,0 +1,82 @@ +"""Service registration for HTML5 integration.""" + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + DOMAIN as NOTIFY_DOMAIN, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import ( + ATTR_ACTION, + ATTR_ACTIONS, + ATTR_BADGE, + ATTR_DIR, + ATTR_ICON, + ATTR_IMAGE, + ATTR_LANG, + ATTR_RENOTIFY, + ATTR_REQUIRE_INTERACTION, + ATTR_SILENT, + ATTR_TAG, + ATTR_TIMESTAMP, + ATTR_TTL, + ATTR_URGENCY, + ATTR_VIBRATE, + DOMAIN, +) + +SERVICE_SEND_MESSAGE = "send_message" + +SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema( + { + vol.Required(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, + vol.Optional(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_DIR): vol.In({"auto", "ltr", "rtl"}), + vol.Optional(ATTR_ICON): cv.string, + vol.Optional(ATTR_BADGE): cv.string, + vol.Optional(ATTR_IMAGE): cv.string, + vol.Optional(ATTR_TAG): cv.string, + vol.Exclusive(ATTR_VIBRATE, "silent_xor_vibrate"): vol.All( + cv.ensure_list, + [vol.All(vol.Coerce(int), vol.Range(min=0))], + ), + vol.Optional(ATTR_TIMESTAMP): cv.datetime, + vol.Optional(ATTR_LANG): cv.language, + vol.Exclusive(ATTR_SILENT, "silent_xor_vibrate"): cv.boolean, + vol.Optional(ATTR_RENOTIFY): cv.boolean, + vol.Optional(ATTR_REQUIRE_INTERACTION): cv.boolean, + vol.Optional(ATTR_URGENCY): vol.In({"normal", "high", "low"}), + vol.Optional(ATTR_TTL): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(ATTR_ACTIONS): vol.All( + cv.ensure_list, + [ + { + vol.Required(ATTR_ACTION): cv.string, + vol.Required(ATTR_TITLE): cv.string, + vol.Optional(ATTR_ICON): cv.string, + } + ], + ), + vol.Optional(ATTR_DATA): dict, + } +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for HTML5 integration.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SEND_MESSAGE, + entity_domain=NOTIFY_DOMAIN, + schema=SERVICE_SEND_MESSAGE_SCHEMA, + func="send_push_notification", + ) diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml index 929eb5a2dc1..5f42fa7e30b 100644 --- a/homeassistant/components/html5/services.yaml +++ b/homeassistant/components/html5/services.yaml @@ -8,3 +8,137 @@ dismiss: example: '{ "tag": "tagname" }' selector: object: +send_message: + target: + entity: + domain: notify + integration: html5 + fields: + title: + required: true + selector: + text: + example: Home Assistant + default: Home Assistant + message: + required: false + selector: + text: + multiline: true + example: Hello World + icon: + required: false + selector: + text: + type: url + example: /static/icons/favicon-192x192.png + badge: + required: false + selector: + text: + type: url + example: /static/images/notification-badge.png + image: + required: false + selector: + text: + type: url + example: /static/images/image.jpg + tag: + required: false + selector: + text: + example: message-group-1 + actions: + selector: + object: + label_field: "action" + description_field: "title" + multiple: true + translation_key: actions + fields: + action: + required: true + selector: + text: + title: + required: true + selector: + text: + icon: + selector: + text: + type: url + example: '[{"action": "test-action", "title": "🆗 Click here!", "icon": "/images/action-1-128x128.png"}]' + dir: + required: false + selector: + select: + options: + - auto + - ltr + - rtl + mode: dropdown + translation_key: dir + example: auto + renotify: + required: false + selector: + constant: + value: true + label: "" + example: true + silent: + required: false + selector: + constant: + value: true + label: "" + example: true + require_interaction: + required: false + selector: + constant: + value: true + label: "" + example: true + vibrate: + required: false + selector: + text: + multiple: true + type: number + suffix: ms + example: "[125,75,125,275,200,275,125,75,125,275,200,600,200,600]" + lang: + required: false + selector: + language: + example: es-419 + timestamp: + required: false + selector: + datetime: + example: "1970-01-01 00:00:00" + ttl: + required: false + selector: + duration: + enable_day: true + example: "{'days': 28}" + urgency: + required: false + selector: + select: + options: + - low + - normal + - high + mode: dropdown + translation_key: urgency + example: normal + data: + required: false + selector: + object: + example: "{'customKey': 'customValue'}" diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index c419451dc2c..5c4dd18830a 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -48,6 +48,44 @@ "message": "Sending notification to {target} failed due to a request error" } }, + "issues": { + "deprecated_notify_action": { + "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `notify.send_message` or `html5.send_message` actions instead.", + "title": "Detected use of deprecated action {action}" + } + }, + "selector": { + "actions": { + "fields": { + "action": { + "description": "The identifier of the action. This will be sent back to Home Assistant when the user clicks the button.", + "name": "Action identifier" + }, + "icon": { + "description": "URL of an image displayed as the icon for this button.", + "name": "Icon" + }, + "title": { + "description": "The label of the button displayed to the user.", + "name": "Title" + } + } + }, + "dir": { + "options": { + "auto": "[%key:common::state::auto%]", + "ltr": "Left-to-right", + "rtl": "Right-to-left" + } + }, + "urgency": { + "options": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]" + } + } + }, "services": { "dismiss": { "description": "Dismisses an HTML5 notification.", @@ -62,6 +100,80 @@ } }, "name": "Dismiss" + }, + "send_message": { + "description": "Sends a message via HTML5 Push Notifications", + "fields": { + "actions": { + "description": "Adds action buttons to the notification. When the user clicks a button, an event is sent back to Home Assistant. Amount of actions supported may vary between platforms.", + "name": "Action buttons" + }, + "badge": { + "description": "URL or relative path of a small image to replace the browser icon on mobile platforms. Maximum size is 96px by 96px", + "name": "Badge" + }, + "data": { + "description": "Additional custom key-value pairs to include in the payload of the push message. This can be used to include extra information that can be accessed in the notification events.", + "name": "Extra data" + }, + "dir": { + "description": "The direction of the notification's text. Adopts the browser's language setting behavior by default.", + "name": "Text direction" + }, + "icon": { + "description": "URL or relative path of an image to display as the main icon in the notification. Maximum size is 320px by 320px.", + "name": "Icon" + }, + "image": { + "description": "URL or relative path of a larger image to display in the main body of the notification. Experimental support, may not be displayed on all platforms.", + "name": "Image" + }, + "lang": { + "description": "The language of the notification's content.", + "name": "Language" + }, + "message": { + "description": "The message body of the notification.", + "name": "Message" + }, + "renotify": { + "description": "If enabled, the user will be alerted again (sound/vibration) when a notification with the same tag replaces a previous one.", + "name": "Renotify" + }, + "require_interaction": { + "description": "If enabled, the notification will remain active until the user clicks or dismisses it, rather than automatically closing after a few seconds. This provides the same behavior on desktop as on mobile platforms.", + "name": "Require interaction" + }, + "silent": { + "description": "If enabled, the notification will not play sounds or trigger vibration, regardless of the device's notification settings.", + "name": "Silent" + }, + "tag": { + "description": "The identifier of the notification. Sending a new notification with the same tag will replace the existing one. If not specified, a unique tag will be generated for each notification.", + "name": "Tag" + }, + "timestamp": { + "description": "The timestamp of the notification. By default, it uses the time when the notification is sent.", + "name": "Timestamp" + }, + "title": { + "description": "Title for your notification message.", + "name": "Title" + }, + "ttl": { + "description": "Specifies how long the push service should retain the message if the user's browser or device is offline. After this period, the notification expires. A value of 0 means the notification is discarded immediately if the target is not connected. Defaults to 1 day.", + "name": "Time to live" + }, + "urgency": { + "description": "Whether the push service should try to deliver the notification immediately or defer it in accordance with the user's power saving preferences.", + "name": "Urgency" + }, + "vibrate": { + "description": "A vibration pattern to run with the notification. An array of integers representing alternating periods of vibration and silence in milliseconds. For example, [200, 100, 200] would vibrate for 200ms, pause for 100ms, then vibrate for another 200ms.", + "name": "Vibration pattern" + } + }, + "name": "Send message" } } } diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index d7a83b2f66e..ef978a7045e 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -3,6 +3,7 @@ from collections.abc import Generator from http import HTTPStatus import json +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch from aiohttp import ClientError @@ -11,9 +12,28 @@ import pytest from pywebpush import WebPushException from syrupy.assertion import SnapshotAssertion -from homeassistant.components.html5 import notify as html5 +from homeassistant.components.html5 import DOMAIN, notify as html5 +from homeassistant.components.html5.const import ( + ATTR_ACTIONS, + ATTR_BADGE, + ATTR_DIR, + ATTR_ICON, + ATTR_IMAGE, + ATTR_LANG, + ATTR_RENOTIFY, + ATTR_REQUIRE_INTERACTION, + ATTR_SILENT, + ATTR_TAG, + ATTR_TIMESTAMP, + ATTR_TTL, + ATTR_URGENCY, + ATTR_VIBRATE, +) +from homeassistant.components.html5.notify import ATTR_ACTION, DEFAULT_TTL from homeassistant.components.notify import ( + ATTR_DATA, ATTR_MESSAGE, + ATTR_TARGET, ATTR_TITLE, DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, @@ -27,7 +47,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, snapshot_platform @@ -817,19 +837,16 @@ async def test_send_message( webpush_async.assert_awaited_once() assert webpush_async.await_args - assert webpush_async.await_args.args == ( - { - "endpoint": "https://googleapis.com", - "keys": {"auth": "auth", "p256dh": "p256dh"}, - }, - '{"badge": "/static/images/notification-badge.png", "body": "World", "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Hello", "timestamp": 1234567890000, "data": {"jwt": "JWT"}}', - "h6acSRds8_KR8hT9djD8WucTL06Gfe29XXyZ1KcUjN8", - { - "sub": "mailto:test@example.com", - "aud": "https://googleapis.com", - "exp": 1234611090, - }, - ) + _, payload, _, _ = webpush_async.await_args.args + assert json.loads(payload) == { + "title": "Hello", + "body": "World", + "badge": "/static/images/notification-badge.png", + "icon": "/static/icons/favicon-192x192.png", + "tag": "12345678-1234-5678-1234-567812345678", + "timestamp": 1234567890000, + "data": {"jwt": "JWT"}, + } @pytest.mark.parametrize( @@ -849,6 +866,7 @@ async def test_send_message( ), ], ) +@pytest.mark.parametrize("domain", [NOTIFY_DOMAIN, DOMAIN]) @pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") @pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_message_exceptions( @@ -858,6 +876,7 @@ async def test_send_message_exceptions( load_config: MagicMock, exception: Exception, translation_key: str, + domain: str, ) -> None: """Test sending a message with exceptions.""" load_config.return_value = {"my-desktop": SUBSCRIPTION_1} @@ -872,7 +891,7 @@ async def test_send_message_exceptions( with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( - NOTIFY_DOMAIN, + domain, SERVICE_SEND_MESSAGE, { ATTR_ENTITY_ID: "notify.my_desktop", @@ -963,3 +982,174 @@ async def test_send_message_unavailable( state = hass.states.get("notify.my_desktop") assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("service_data", "expected_payload", "expected_ttl", "expected_headers"), + [ + ({ATTR_MESSAGE: "World"}, {"body": "World"}, DEFAULT_TTL, None), + ( + {ATTR_ICON: "/static/icons/favicon-192x192.png"}, + {"icon": "/static/icons/favicon-192x192.png"}, + DEFAULT_TTL, + None, + ), + ( + {ATTR_BADGE: "/static/images/notification-badge.png"}, + {"badge": "/static/images/notification-badge.png"}, + DEFAULT_TTL, + None, + ), + ( + {ATTR_IMAGE: "/static/images/image.jpg"}, + {"image": "/static/images/image.jpg"}, + DEFAULT_TTL, + None, + ), + ({ATTR_TAG: "message-group-1"}, {"tag": "message-group-1"}, DEFAULT_TTL, None), + ({ATTR_DIR: "rtl"}, {"dir": "rtl"}, DEFAULT_TTL, None), + ({ATTR_RENOTIFY: True}, {"renotify": True}, DEFAULT_TTL, None), + ({ATTR_SILENT: True}, {"silent": True}, DEFAULT_TTL, None), + ( + {ATTR_REQUIRE_INTERACTION: True}, + {"requireInteraction": True}, + DEFAULT_TTL, + None, + ), + ( + {ATTR_VIBRATE: [200, 100, 200]}, + {"vibrate": [200, 100, 200]}, + DEFAULT_TTL, + None, + ), + ({ATTR_LANG: "es-419"}, {"lang": "es-419"}, DEFAULT_TTL, None), + ({ATTR_TIMESTAMP: "1970-01-01 00:00:00"}, {"timestamp": 0}, DEFAULT_TTL, None), + ({ATTR_TTL: {"days": 28}}, {}, 2419200, None), + ({ATTR_TTL: {"seconds": 0}}, {}, 0, None), + ( + {ATTR_URGENCY: "high"}, + {}, + DEFAULT_TTL, + {"Urgency": "high"}, + ), + ( + { + ATTR_ACTIONS: [ + { + ATTR_ACTION: "callback-event", + ATTR_TITLE: "Callback Event", + ATTR_ICON: "/static/icons/favicon-192x192.png", + } + ] + }, + { + "actions": [ + { + "action": "callback-event", + "title": "Callback Event", + "icon": "/static/icons/favicon-192x192.png", + } + ] + }, + DEFAULT_TTL, + None, + ), + ( + {ATTR_DATA: {"customKey": "customValue"}}, + {"data": {"jwt": "JWT", "customKey": "customValue"}}, + DEFAULT_TTL, + None, + ), + ], +) +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_html5_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + webpush_async: AsyncMock, + load_config: MagicMock, + service_data: dict[str, Any], + expected_payload: dict[str, Any], + expected_ttl: int, + expected_headers: dict[str, Any] | None, +) -> None: + """Test sending a message via html5.send_message action.""" + 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 + + state = hass.states.get("notify.my_desktop") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + {ATTR_ENTITY_ID: "notify.my_desktop", ATTR_TITLE: "Hello", **service_data}, + blocking=True, + ) + + state = hass.states.get("notify.my_desktop") + assert state + assert state.state == "2009-02-13T23:31:30+00:00" + + webpush_async.assert_awaited_once() + assert webpush_async.await_args + _, payload, _, _ = webpush_async.await_args.args + assert json.loads(payload) == { + "title": "Hello", + "tag": "12345678-1234-5678-1234-567812345678", + "timestamp": 1234567890000, + "data": {"jwt": "JWT"}, + **expected_payload, + } + + assert webpush_async.await_args.kwargs["ttl"] == expected_ttl + assert webpush_async.await_args.kwargs["headers"] == expected_headers + + +@pytest.mark.parametrize( + ("target", "issue_id"), + [ + (["my-desktop"], "deprecated_notify_action_notify.html5_my_desktop"), + (None, "deprecated_notify_action_notify.html5"), + (["my-desktop", "my-phone"], "deprecated_notify_action_notify.html5"), + ], +) +@pytest.mark.usefixtures("mock_wp", "mock_jwt", "mock_vapid", "mock_uuid") +async def test_deprecation_action_call( + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, + issue_registry: ir.IssueRegistry, + target: list[str] | None, + issue_id: str, +) -> None: + """Test deprecation action call.""" + load_config.return_value = { + "my-desktop": SUBSCRIPTION_1, + "my-phone": SUBSCRIPTION_2, + } + + 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 hass.services.async_call( + NOTIFY_DOMAIN, + DOMAIN, + {ATTR_MESSAGE: "Hello", ATTR_TARGET: target}, + blocking=True, + ) + + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=issue_id, + )