Refactor HTML5 integration to use aiohttp instead of requests (#163202)

This commit is contained in:
Manu
2026-02-16 20:11:04 +01:00
committed by GitHub
parent 957c6039e9
commit 47d6e3e938
3 changed files with 186 additions and 117 deletions

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from contextlib import suppress
from datetime import datetime, timedelta
from functools import partial
from http import HTTPStatus
import json
import logging
@@ -13,7 +12,7 @@ from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast
from urllib.parse import urlparse
import uuid
from aiohttp import web
from aiohttp import ClientSession, web
from aiohttp.hdrs import AUTHORIZATION
import jwt
from py_vapid import Vapid
@@ -35,6 +34,7 @@ from homeassistant.const import ATTR_NAME, URL_ROOT
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.json import save_json
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import ensure_unique_string
@@ -203,8 +203,9 @@ async def async_get_service(
hass.http.register_view(HTML5PushRegistrationView(registrations, json_path))
hass.http.register_view(HTML5PushCallbackView(registrations))
session = async_get_clientsession(hass)
return HTML5NotificationService(
hass, vapid_prv_key, vapid_email, registrations, json_path
hass, session, vapid_prv_key, vapid_email, registrations, json_path
)
@@ -420,12 +421,14 @@ class HTML5NotificationService(BaseNotificationService):
def __init__(
self,
hass: HomeAssistant,
session: ClientSession,
vapid_prv: str,
vapid_email: str,
registrations: dict[str, Registration],
json_path: str,
) -> None:
"""Initialize the service."""
self.session = session
self._vapid_prv = vapid_prv
self._vapid_email = vapid_email
self.registrations = registrations
@@ -456,22 +459,18 @@ class HTML5NotificationService(BaseNotificationService):
"""Return a dictionary of registered targets."""
return {registration: registration for registration in self.registrations}
def dismiss(self, **kwargs: Any) -> None:
"""Dismisses a notification."""
data: dict[str, Any] | None = kwargs.get(ATTR_DATA)
tag: str = data.get(ATTR_TAG, "") if data else ""
payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}}
self._push_message(payload, **kwargs)
async def async_dismiss(self, **kwargs) -> None:
async def async_dismiss(self, **kwargs: Any) -> None:
"""Dismisses a notification.
This method must be run in the event loop.
"""
await self.hass.async_add_executor_job(partial(self.dismiss, **kwargs))
data: dict[str, Any] | None = kwargs.get(ATTR_DATA)
tag: str = data.get(ATTR_TAG, "") if data else ""
payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}}
def send_message(self, message: str = "", **kwargs: Any) -> None:
await self._push_message(payload, **kwargs)
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
tag = str(uuid.uuid4())
payload: dict[str, Any] = {
@@ -503,9 +502,9 @@ class HTML5NotificationService(BaseNotificationService):
):
payload[ATTR_DATA][ATTR_URL] = URL_ROOT
self._push_message(payload, **kwargs)
await self._push_message(payload, **kwargs)
def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None:
async def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None:
"""Send the message."""
timestamp = int(time.time())
@@ -535,7 +534,9 @@ class HTML5NotificationService(BaseNotificationService):
subscription["keys"]["auth"],
)
webpusher = WebPusher(cast(dict[str, Any], info["subscription"]))
webpusher = WebPusher(
cast(dict[str, Any], info["subscription"]), aiohttp_session=self.session
)
endpoint = urlparse(subscription["endpoint"])
vapid_claims = {
@@ -545,28 +546,31 @@ class HTML5NotificationService(BaseNotificationService):
}
vapid_headers = Vapid.from_string(self._vapid_prv).sign(vapid_claims)
vapid_headers.update({"urgency": priority, "priority": priority})
response = webpusher.send(
response = await webpusher.send_async(
data=json.dumps(payload), headers=vapid_headers, ttl=ttl
)
if TYPE_CHECKING:
assert not isinstance(response, str)
if response.status_code == HTTPStatus.GONE:
if response.status == HTTPStatus.GONE:
_LOGGER.info("Notification channel has expired")
reg = self.registrations.pop(target)
try:
save_json(self.registrations_json_path, self.registrations)
await self.hass.async_add_executor_job(
save_json, self.registrations_json_path, self.registrations
)
except HomeAssistantError:
self.registrations[target] = reg
_LOGGER.error("Error saving registration")
else:
_LOGGER.info("Configuration saved")
elif response.status_code >= HTTPStatus.BAD_REQUEST:
elif response.status >= HTTPStatus.BAD_REQUEST:
_LOGGER.error(
"There was an issue sending the notification %s: %s",
response.status_code,
response.text,
response.status,
await response.text(),
)

View File

@@ -1,8 +1,9 @@
"""Common fixtures for html5 integration."""
from collections.abc import Generator
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
from aiohttp import ClientResponse
import pytest
from homeassistant.components.html5.const import (
@@ -45,3 +46,58 @@ def mock_load_config() -> Generator[MagicMock]:
"homeassistant.components.html5.notify._load_config", return_value={}
) as mock_load_config:
yield mock_load_config
@pytest.fixture
def mock_wp() -> Generator[AsyncMock]:
"""Mock WebPusher."""
with (
patch(
"homeassistant.components.html5.notify.WebPusher", autospec=True
) as mock_client,
):
client = mock_client.return_value
client.cls = mock_client
client.send_async.return_value = AsyncMock(spec=ClientResponse, status=201)
yield client
@pytest.fixture
def mock_jwt() -> Generator[MagicMock]:
"""Mock JWT."""
with (
patch("homeassistant.components.html5.notify.jwt") as mock_client,
):
mock_client.encode.return_value = "JWT"
mock_client.decode.return_value = {"target": "device"}
yield mock_client
@pytest.fixture
def mock_uuid() -> Generator[MagicMock]:
"""Mock UUID."""
with (
patch("homeassistant.components.html5.notify.uuid") as mock_client,
):
mock_client.uuid4.return_value = "12345678-1234-5678-1234-567812345678"
yield mock_client
@pytest.fixture
def mock_vapid() -> Generator[MagicMock]:
"""Mock VAPID headers."""
with (
patch(
"homeassistant.components.html5.notify.Vapid", autospec=True
) as mock_client,
):
mock_client.from_string.return_value.sign.return_value = {
"Authorization": "vapid t=signed!!!",
"urgency": "normal",
"priority": "normal",
}
yield mock_client

View File

@@ -2,7 +2,7 @@
from http import HTTPStatus
import json
from unittest.mock import MagicMock, mock_open, patch
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
from aiohttp.hdrs import AUTHORIZATION
import pytest
@@ -71,6 +71,12 @@ SUBSCRIPTION_5 = {
REGISTER_URL = "/api/notify.html5"
PUBLISH_URL = "/api/notify.html5/callback"
VAPID_HEADERS = {
"Authorization": "vapid t=signed!!!",
"urgency": "normal",
"priority": "normal",
}
async def test_get_service_with_no_json(hass: HomeAssistant) -> None:
"""Test empty json file."""
@@ -82,11 +88,11 @@ async def test_get_service_with_no_json(hass: HomeAssistant) -> None:
assert service is not None
@patch("homeassistant.components.html5.notify.WebPusher")
async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_jwt", "mock_vapid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_dismissing_message(mock_wp: AsyncMock, hass: HomeAssistant) -> None:
"""Test dismissing message."""
await async_setup_component(hass, "http", {})
mock_wp().send().status_code = 201
data = {"device": SUBSCRIPTION_1}
@@ -99,23 +105,18 @@ async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None:
await service.async_dismiss(target=["device", "non_existing"], data={"tag": "test"})
assert len(mock_wp.mock_calls) == 4
# WebPusher constructor
assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"]
# Call to send
payload = json.loads(mock_wp.mock_calls[3][2]["data"])
assert payload["dismiss"] is True
assert payload["tag"] == "test"
mock_wp.send_async.assert_awaited_once_with(
data='{"tag": "test", "dismiss": true, "data": {"jwt": "JWT"}, "timestamp": 1234567890000}',
headers=VAPID_HEADERS,
ttl=86400,
)
@patch("homeassistant.components.html5.notify.WebPusher")
async def test_sending_message(mock_wp, hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_sending_message(mock_wp: AsyncMock, hass: HomeAssistant) -> None:
"""Test sending message."""
await async_setup_component(hass, "http", {})
mock_wp().send().status_code = 201
data = {"device": SUBSCRIPTION_1}
@@ -130,23 +131,21 @@ async def test_sending_message(mock_wp, hass: HomeAssistant) -> None:
"Hello", target=["device", "non_existing"], data={"icon": "beer.png"}
)
assert len(mock_wp.mock_calls) == 4
mock_wp.send_async.assert_awaited_once_with(
data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}',
headers=VAPID_HEADERS,
ttl=86400,
)
# WebPusher constructor
assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"]
# Call to send
payload = json.loads(mock_wp.mock_calls[3][2]["data"])
assert payload["body"] == "Hello"
assert payload["icon"] == "beer.png"
assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"]
@patch("homeassistant.components.html5.notify.WebPusher")
async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_fcm_key_include(mock_wp: AsyncMock, hass: HomeAssistant) -> None:
"""Test if the FCM header is included."""
await async_setup_component(hass, "http", {})
mock_wp().send().status_code = 201
data = {"chrome": SUBSCRIPTION_5}
@@ -159,19 +158,23 @@ async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None:
await service.async_send_message("Hello", target=["chrome"])
assert len(mock_wp.mock_calls) == 4
mock_wp.send_async.assert_awaited_once_with(
data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}',
headers=VAPID_HEADERS,
ttl=86400,
)
# WebPusher constructor
assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"]
# Get the keys passed to the WebPusher's send method
assert mock_wp.mock_calls[3][2]["headers"]["Authorization"] is not None
assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"]
@patch("homeassistant.components.html5.notify.WebPusher")
async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_fcm_send_with_unknown_priority(
mock_wp: AsyncMock, hass: HomeAssistant
) -> None:
"""Test if the gcm_key is only included for GCM endpoints."""
await async_setup_component(hass, "http", {})
mock_wp().send().status_code = 201
data = {"chrome": SUBSCRIPTION_5}
@@ -184,19 +187,20 @@ async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> N
await service.async_send_message("Hello", target=["chrome"], priority="undefined")
assert len(mock_wp.mock_calls) == 4
mock_wp.send_async.assert_awaited_once_with(
data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}',
headers=VAPID_HEADERS,
ttl=86400,
)
# WebPusher constructor
assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"]
# Get the keys passed to the WebPusher's send method
assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal"
assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"]
@patch("homeassistant.components.html5.notify.WebPusher")
async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_fcm_no_targets(mock_wp: AsyncMock, hass: HomeAssistant) -> None:
"""Test if the gcm_key is only included for GCM endpoints."""
await async_setup_component(hass, "http", {})
mock_wp().send().status_code = 201
data = {"chrome": SUBSCRIPTION_5}
@@ -209,19 +213,20 @@ async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None:
await service.async_send_message("Hello")
assert len(mock_wp.mock_calls) == 4
mock_wp.send_async.assert_awaited_once_with(
data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}',
headers=VAPID_HEADERS,
ttl=86400,
)
# WebPusher constructor
assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"]
# Get the keys passed to the WebPusher's send method
assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal"
assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"]
@patch("homeassistant.components.html5.notify.WebPusher")
async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_fcm_additional_data(mock_wp: AsyncMock, hass: HomeAssistant) -> None:
"""Test if the gcm_key is only included for GCM endpoints."""
await async_setup_component(hass, "http", {})
mock_wp().send().status_code = 201
data = {"chrome": SUBSCRIPTION_5}
@@ -234,12 +239,13 @@ async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None:
await service.async_send_message("Hello", data={"mykey": "myvalue"})
assert len(mock_wp.mock_calls) == 4
mock_wp.send_async.assert_awaited_once_with(
data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"mykey": "myvalue", "url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}',
headers=VAPID_HEADERS,
ttl=86400,
)
# WebPusher constructor
assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"]
# Get the keys passed to the WebPusher's send method
assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal"
assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"]
@pytest.mark.usefixtures("load_config")
@@ -581,11 +587,14 @@ async def test_callback_view_no_jwt(
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_callback_view_with_jwt(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
config_entry: MockConfigEntry,
load_config: MagicMock,
mock_wp: AsyncMock,
) -> None:
"""Test that the notification callback view works with JWT."""
load_config.return_value = {"device": SUBSCRIPTION_1}
@@ -599,27 +608,22 @@ async def test_callback_view_with_jwt(
client = await hass_client()
with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp:
mock_wp().send().status_code = 201
await hass.services.async_call(
"notify",
"html5",
{"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}},
blocking=True,
)
assert len(mock_wp.mock_calls) == 4
await hass.services.async_call(
"notify",
"html5",
{"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}},
blocking=True,
)
mock_wp.send_async.assert_awaited_once_with(
data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}',
headers=VAPID_HEADERS,
ttl=86400,
)
# WebPusher constructor
assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"]
assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"]
# Call to send
push_payload = json.loads(mock_wp.mock_calls[3][2]["data"])
assert push_payload["body"] == "Hello"
assert push_payload["icon"] == "beer.png"
bearer_token = f"Bearer {push_payload['data']['jwt']}"
bearer_token = "Bearer JWT"
resp = await client.post(
PUBLISH_URL, json={"type": "push"}, headers={AUTHORIZATION: bearer_token}
@@ -630,10 +634,13 @@ async def test_callback_view_with_jwt(
assert body == {"event": "push", "status": "ok"}
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_send_fcm_without_targets(
hass: HomeAssistant,
config_entry: MockConfigEntry,
load_config: MagicMock,
mock_wp: AsyncMock,
) -> None:
"""Test that the notification is send with FCM without targets."""
load_config.return_value = {"device": SUBSCRIPTION_5}
@@ -645,25 +652,29 @@ async def test_send_fcm_without_targets(
assert config_entry.state is ConfigEntryState.LOADED
with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp:
mock_wp().send().status_code = 201
await hass.services.async_call(
"notify",
"html5",
{"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}},
blocking=True,
)
assert len(mock_wp.mock_calls) == 4
await hass.services.async_call(
"notify",
"html5",
{"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}},
blocking=True,
)
mock_wp.send_async.assert_awaited_once_with(
data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}',
headers=VAPID_HEADERS,
ttl=86400,
)
# WebPusher constructor
assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"]
assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"]
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_send_fcm_expired(
hass: HomeAssistant,
config_entry: MockConfigEntry,
load_config: MagicMock,
mock_wp: AsyncMock,
) -> None:
"""Test that the FCM target is removed when expired."""
load_config.return_value = {"device": SUBSCRIPTION_5}
@@ -674,12 +685,10 @@ async def test_send_fcm_expired(
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
mock_wp.send_async.return_value.status = 410
with (
patch("homeassistant.components.html5.notify.WebPusher") as mock_wp,
patch("homeassistant.components.html5.notify.save_json") as mock_save,
):
mock_wp().send().status_code = 410
await hass.services.async_call(
"notify",
"html5",
@@ -690,11 +699,14 @@ async def test_send_fcm_expired(
mock_save.assert_called_once_with(hass.config.path(html5.REGISTRATIONS_FILE), {})
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
async def test_send_fcm_expired_save_fails(
hass: HomeAssistant,
config_entry: MockConfigEntry,
load_config: MagicMock,
caplog: pytest.LogCaptureFixture,
mock_wp: AsyncMock,
) -> None:
"""Test that the FCM target remains after expiry if save_json fails."""
load_config.return_value = {"device": SUBSCRIPTION_5}
@@ -705,16 +717,13 @@ async def test_send_fcm_expired_save_fails(
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
mock_wp.send_async.return_value.status = 410
with (
patch("homeassistant.components.html5.notify.WebPusher") as mock_wp,
patch(
"homeassistant.components.html5.notify.save_json",
side_effect=HomeAssistantError(),
),
):
mock_wp().send().status_code = 410
await hass.services.async_call(
"notify",
"html5",