1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-18 07:56:03 +01:00

Add entity action html5.send_message to HTML5 integration (#166349)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Manu
2026-03-30 19:49:59 +02:00
committed by GitHub
parent dc111a475e
commit 52af74c3b6
9 changed files with 641 additions and 38 deletions

View File

@@ -4,14 +4,23 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN from .const import DOMAIN
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.EVENT, Platform.NOTIFY] 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HTML5 from a config entry.""" """Set up HTML5 from a config entry."""
hass.async_create_task( hass.async_create_task(

View File

@@ -11,5 +11,18 @@ ATTR_VAPID_EMAIL = "vapid_email"
REGISTRATIONS_FILE = "html5_push_registrations.conf" REGISTRATIONS_FILE = "html5_push_registrations.conf"
ATTR_ACTION = "action" ATTR_ACTION = "action"
ATTR_ACTIONS = "actions"
ATTR_BADGE = "badge"
ATTR_DATA = "data" 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_TAG = "tag"
ATTR_TIMESTAMP = "timestamp"
ATTR_TTL = "ttl"
ATTR_URGENCY = "urgency"
ATTR_VIBRATE = "vibrate"

View File

@@ -9,6 +9,9 @@
"services": { "services": {
"dismiss": { "dismiss": {
"service": "mdi:bell-off" "service": "mdi:bell-off"
},
"send_message": {
"service": "mdi:message-arrow-right"
} }
} }
} }

View File

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

View File

@@ -47,7 +47,11 @@ from homeassistant.util.json import load_json_object
from .const import ( from .const import (
ATTR_ACTION, ATTR_ACTION,
ATTR_ACTIONS,
ATTR_REQUIRE_INTERACTION,
ATTR_TAG, ATTR_TAG,
ATTR_TIMESTAMP,
ATTR_TTL,
ATTR_VAPID_EMAIL, ATTR_VAPID_EMAIL,
ATTR_VAPID_PRV_KEY, ATTR_VAPID_PRV_KEY,
ATTR_VAPID_PUB_KEY, ATTR_VAPID_PUB_KEY,
@@ -56,6 +60,7 @@ from .const import (
SERVICE_DISMISS, SERVICE_DISMISS,
) )
from .entity import HTML5Entity, Registration from .entity import HTML5Entity, Registration
from .issue import deprecated_notify_action_call
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -69,13 +74,11 @@ ATTR_AUTH = "auth"
ATTR_P256DH = "p256dh" ATTR_P256DH = "p256dh"
ATTR_EXPIRATIONTIME = "expirationTime" ATTR_EXPIRATIONTIME = "expirationTime"
ATTR_ACTIONS = "actions"
ATTR_TYPE = "type" ATTR_TYPE = "type"
ATTR_URL = "url" ATTR_URL = "url"
ATTR_DISMISS = "dismiss" ATTR_DISMISS = "dismiss"
ATTR_PRIORITY = "priority" ATTR_PRIORITY = "priority"
DEFAULT_PRIORITY = "normal" DEFAULT_PRIORITY = "normal"
ATTR_TTL = "ttl"
DEFAULT_TTL = 86400 DEFAULT_TTL = 86400
DEFAULT_BADGE = "/static/images/notification-badge.png" 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: async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user.""" """Send a message to a user."""
deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET))
tag = str(uuid.uuid4()) tag = str(uuid.uuid4())
payload: dict[str, Any] = { payload: dict[str, Any] = {
"badge": DEFAULT_BADGE, "badge": DEFAULT_BADGE,
@@ -605,32 +611,53 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
_key = "device" _key = "device"
async def async_send_message(self, message: str, title: str | None = None) -> None: async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to a device.""" """Send a message to a device via notify.send_message action."""
timestamp = int(time.time()) await self._webpush(
tag = str(uuid.uuid4()) title=title or ATTR_TITLE_DEFAULT,
message=message,
badge=DEFAULT_BADGE,
icon=DEFAULT_ICON,
)
payload: dict[str, Any] = { async def send_push_notification(self, **kwargs: Any) -> None:
"badge": DEFAULT_BADGE, """Send a message to a device via html5.send_message action."""
"body": message, await self._webpush(**kwargs)
"icon": DEFAULT_ICON, self._async_record_notification()
ATTR_TAG: tag,
ATTR_TITLE: title or ATTR_TITLE_DEFAULT, async def _webpush(
"timestamp": timestamp * 1000, self,
ATTR_DATA: { message: str | None = None,
ATTR_JWT: add_jwt( timestamp: datetime | None = None,
timestamp, 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, self.target,
tag, payload[ATTR_TAG],
self.registration["subscription"]["keys"]["auth"], self.registration["subscription"]["keys"]["auth"],
) )
},
}
endpoint = urlparse(self.registration["subscription"]["endpoint"]) endpoint = urlparse(self.registration["subscription"]["endpoint"])
vapid_claims = { vapid_claims = {
"sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}", "sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}",
"aud": f"{endpoint.scheme}://{endpoint.netloc}", "aud": f"{endpoint.scheme}://{endpoint.netloc}",
"exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60), "exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
} }
try: try:
@@ -639,6 +666,8 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
json.dumps(payload), json.dumps(payload),
self.config_entry.data[ATTR_VAPID_PRV_KEY], self.config_entry.data[ATTR_VAPID_PRV_KEY],
vapid_claims, 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, aiohttp_session=self.session,
) )
cast(ClientResponse, response).raise_for_status() cast(ClientResponse, response).raise_for_status()

View File

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

View File

@@ -8,3 +8,137 @@ dismiss:
example: '{ "tag": "tagname" }' example: '{ "tag": "tagname" }'
selector: selector:
object: 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'}"

View File

@@ -48,6 +48,44 @@
"message": "Sending notification to {target} failed due to a request error" "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": { "services": {
"dismiss": { "dismiss": {
"description": "Dismisses an HTML5 notification.", "description": "Dismisses an HTML5 notification.",
@@ -62,6 +100,80 @@
} }
}, },
"name": "Dismiss" "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"
} }
} }
} }

View File

@@ -3,6 +3,7 @@
from collections.abc import Generator from collections.abc import Generator
from http import HTTPStatus from http import HTTPStatus
import json import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
from aiohttp import ClientError from aiohttp import ClientError
@@ -11,9 +12,28 @@ import pytest
from pywebpush import WebPushException from pywebpush import WebPushException
from syrupy.assertion import SnapshotAssertion 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 ( from homeassistant.components.notify import (
ATTR_DATA,
ATTR_MESSAGE, ATTR_MESSAGE,
ATTR_TARGET,
ATTR_TITLE, ATTR_TITLE,
DOMAIN as NOTIFY_DOMAIN, DOMAIN as NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE, SERVICE_SEND_MESSAGE,
@@ -27,7 +47,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError 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 homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, snapshot_platform from tests.common import MockConfigEntry, snapshot_platform
@@ -817,19 +837,16 @@ async def test_send_message(
webpush_async.assert_awaited_once() webpush_async.assert_awaited_once()
assert webpush_async.await_args assert webpush_async.await_args
assert webpush_async.await_args.args == ( _, payload, _, _ = webpush_async.await_args.args
{ assert json.loads(payload) == {
"endpoint": "https://googleapis.com", "title": "Hello",
"keys": {"auth": "auth", "p256dh": "p256dh"}, "body": "World",
}, "badge": "/static/images/notification-badge.png",
'{"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"}}', "icon": "/static/icons/favicon-192x192.png",
"h6acSRds8_KR8hT9djD8WucTL06Gfe29XXyZ1KcUjN8", "tag": "12345678-1234-5678-1234-567812345678",
{ "timestamp": 1234567890000,
"sub": "mailto:test@example.com", "data": {"jwt": "JWT"},
"aud": "https://googleapis.com", }
"exp": 1234611090,
},
)
@pytest.mark.parametrize( @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.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") @pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_send_message_exceptions( async def test_send_message_exceptions(
@@ -858,6 +876,7 @@ async def test_send_message_exceptions(
load_config: MagicMock, load_config: MagicMock,
exception: Exception, exception: Exception,
translation_key: str, translation_key: str,
domain: str,
) -> None: ) -> None:
"""Test sending a message with exceptions.""" """Test sending a message with exceptions."""
load_config.return_value = {"my-desktop": SUBSCRIPTION_1} load_config.return_value = {"my-desktop": SUBSCRIPTION_1}
@@ -872,7 +891,7 @@ async def test_send_message_exceptions(
with pytest.raises(HomeAssistantError) as e: with pytest.raises(HomeAssistantError) as e:
await hass.services.async_call( await hass.services.async_call(
NOTIFY_DOMAIN, domain,
SERVICE_SEND_MESSAGE, SERVICE_SEND_MESSAGE,
{ {
ATTR_ENTITY_ID: "notify.my_desktop", ATTR_ENTITY_ID: "notify.my_desktop",
@@ -963,3 +982,174 @@ async def test_send_message_unavailable(
state = hass.states.get("notify.my_desktop") state = hass.states.get("notify.my_desktop")
assert state assert state
assert state.state == STATE_UNAVAILABLE 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,
)