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,
+ )