mirror of
https://github.com/home-assistant/core.git
synced 2026-02-14 23:28:42 +00:00
Add support for attachments from media sources in ntfy notifications (#152329)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,8 @@ from aiontfy.exceptions import (
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components import camera, image
|
||||
from homeassistant.components.media_source import async_resolve_media
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_MESSAGE,
|
||||
ATTR_TITLE,
|
||||
@@ -26,6 +28,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.selector import MediaSelector
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import NtfyConfigEntry
|
||||
@@ -49,25 +52,48 @@ ATTR_MARKDOWN = "markdown"
|
||||
ATTR_PRIORITY = "priority"
|
||||
ATTR_TAGS = "tags"
|
||||
ATTR_SEQUENCE_ID = "sequence_id"
|
||||
ATTR_ATTACH_FILE = "attach_file"
|
||||
ATTR_FILENAME = "filename"
|
||||
GRP_ATTACHMENT = "attachment"
|
||||
MSG_ATTACHMENT = "Only one attachment source is allowed: URL or local file"
|
||||
|
||||
SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional(ATTR_TITLE): cv.string,
|
||||
vol.Optional(ATTR_MESSAGE): cv.string,
|
||||
vol.Optional(ATTR_MARKDOWN): cv.boolean,
|
||||
vol.Optional(ATTR_TAGS): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_PRIORITY): vol.All(vol.Coerce(int), vol.Range(1, 5)),
|
||||
vol.Optional(ATTR_CLICK): vol.All(vol.Url(), vol.Coerce(URL)),
|
||||
vol.Optional(ATTR_DELAY): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(seconds=10), max=timedelta(days=3)),
|
||||
),
|
||||
vol.Optional(ATTR_ATTACH): vol.All(vol.Url(), vol.Coerce(URL)),
|
||||
vol.Optional(ATTR_EMAIL): vol.Email(),
|
||||
vol.Optional(ATTR_CALL): cv.string,
|
||||
vol.Optional(ATTR_ICON): vol.All(vol.Url(), vol.Coerce(URL)),
|
||||
vol.Optional(ATTR_SEQUENCE_ID): cv.string,
|
||||
}
|
||||
|
||||
def validate_filename(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate filename."""
|
||||
if ATTR_FILENAME in params and not (
|
||||
ATTR_ATTACH_FILE in params or ATTR_ATTACH in params
|
||||
):
|
||||
raise vol.Invalid("Filename only allowed when attachment is provided")
|
||||
return params
|
||||
|
||||
|
||||
SERVICE_PUBLISH_SCHEMA = vol.All(
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional(ATTR_TITLE): cv.string,
|
||||
vol.Optional(ATTR_MESSAGE): cv.string,
|
||||
vol.Optional(ATTR_MARKDOWN): cv.boolean,
|
||||
vol.Optional(ATTR_TAGS): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_PRIORITY): vol.All(vol.Coerce(int), vol.Range(1, 5)),
|
||||
vol.Optional(ATTR_CLICK): vol.All(vol.Url(), vol.Coerce(URL)),
|
||||
vol.Optional(ATTR_DELAY): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(seconds=10), max=timedelta(days=3)),
|
||||
),
|
||||
vol.Optional(ATTR_EMAIL): vol.Email(),
|
||||
vol.Optional(ATTR_CALL): cv.string,
|
||||
vol.Optional(ATTR_ICON): vol.All(vol.Url(), vol.Coerce(URL)),
|
||||
vol.Optional(ATTR_SEQUENCE_ID): cv.string,
|
||||
vol.Exclusive(ATTR_ATTACH, GRP_ATTACHMENT, MSG_ATTACHMENT): vol.All(
|
||||
vol.Url(), vol.Coerce(URL)
|
||||
),
|
||||
vol.Exclusive(
|
||||
ATTR_ATTACH_FILE, GRP_ATTACHMENT, MSG_ATTACHMENT
|
||||
): MediaSelector({"accept": ["*/*"]}),
|
||||
vol.Optional(ATTR_FILENAME): cv.string,
|
||||
}
|
||||
),
|
||||
validate_filename,
|
||||
)
|
||||
|
||||
SERVICE_CLEAR_DELETE_SCHEMA = cv.make_entity_service_schema(
|
||||
@@ -129,7 +155,7 @@ class NtfyNotifyEntity(NtfyBaseEntity, NotifyEntity):
|
||||
|
||||
async def publish(self, **kwargs: Any) -> None:
|
||||
"""Publish a message to a topic."""
|
||||
|
||||
attachment = None
|
||||
params: dict[str, Any] = kwargs
|
||||
delay: timedelta | None = params.get("delay")
|
||||
if delay:
|
||||
@@ -144,10 +170,36 @@ class NtfyNotifyEntity(NtfyBaseEntity, NotifyEntity):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="delay_no_call",
|
||||
)
|
||||
if file := params.pop(ATTR_ATTACH_FILE, None):
|
||||
media_content_id: str = file["media_content_id"]
|
||||
if media_content_id.startswith("media-source://camera/"):
|
||||
entity_id = media_content_id.removeprefix("media-source://camera/")
|
||||
attachment = (
|
||||
await camera.async_get_image(self.hass, entity_id)
|
||||
).content
|
||||
elif media_content_id.startswith("media-source://image/"):
|
||||
entity_id = media_content_id.removeprefix("media-source://image/")
|
||||
attachment = (await image.async_get_image(self.hass, entity_id)).content
|
||||
else:
|
||||
media = await async_resolve_media(
|
||||
self.hass, file["media_content_id"], None
|
||||
)
|
||||
|
||||
if media.path is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="media_source_not_supported",
|
||||
)
|
||||
|
||||
attachment = await self.hass.async_add_executor_job(
|
||||
media.path.read_bytes
|
||||
)
|
||||
|
||||
params.setdefault(ATTR_FILENAME, media.path.name)
|
||||
|
||||
msg = Message(topic=self.topic, **params)
|
||||
try:
|
||||
await self.ntfy.publish(msg)
|
||||
await self.ntfy.publish(msg, attachment)
|
||||
except NtfyUnauthorizedAuthenticationError as e:
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
raise HomeAssistantError(
|
||||
|
||||
@@ -67,6 +67,17 @@ publish:
|
||||
type: url
|
||||
autocomplete: url
|
||||
example: https://example.org/download.zip
|
||||
attach_file:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- "*"
|
||||
filename:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
example: attachment.jpg
|
||||
email:
|
||||
required: false
|
||||
selector:
|
||||
|
||||
@@ -288,6 +288,9 @@
|
||||
"entry_not_loaded": {
|
||||
"message": "The selected ntfy service is currently not loaded or disabled in Home Assistant."
|
||||
},
|
||||
"media_source_not_supported": {
|
||||
"message": "Media source currently not supported"
|
||||
},
|
||||
"publish_failed_exception": {
|
||||
"message": "Failed to publish notification due to a connection error"
|
||||
},
|
||||
@@ -353,6 +356,10 @@
|
||||
"description": "Attach images or other files by URL.",
|
||||
"name": "Attachment URL"
|
||||
},
|
||||
"attach_file": {
|
||||
"description": "Attach images or other files by uploading from a local file, camera, or image media source. When selecting a camera entity, a snapshot of the current view will be captured and attached to the notification.",
|
||||
"name": "Attach local file"
|
||||
},
|
||||
"call": {
|
||||
"description": "Phone number to call and read the message out loud using text-to-speech. Requires ntfy Pro and prior phone number verification.",
|
||||
"name": "Phone call"
|
||||
@@ -369,6 +376,10 @@
|
||||
"description": "Specify the address to forward the notification to, for example mail@example.com",
|
||||
"name": "Forward to email"
|
||||
},
|
||||
"filename": {
|
||||
"description": "Specify a custom filename for the attachment, including the file extension (for example, attachment.jpg). If not provided, the original filename will be used.",
|
||||
"name": "Attachment filename"
|
||||
},
|
||||
"icon": {
|
||||
"description": "Include an icon that will appear next to the text of the notification. Only JPEG and PNG images are supported.",
|
||||
"name": "Icon URL"
|
||||
|
||||
@@ -90,7 +90,7 @@ async def test_send_message(
|
||||
assert state.state == "2025-01-09T12:00:00+00:00"
|
||||
|
||||
mock_aiontfy.publish.assert_called_once_with(
|
||||
Message(topic="mytopic", message="triggered", title="test")
|
||||
Message(topic="mytopic", message="triggered", title="test"), None
|
||||
)
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ async def test_send_message_exception(
|
||||
)
|
||||
|
||||
mock_aiontfy.publish.assert_called_once_with(
|
||||
Message(topic="mytopic", message="triggered", title="test")
|
||||
Message(topic="mytopic", message="triggered", title="test"), None
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,16 +9,20 @@ from aiontfy.exceptions import (
|
||||
NtfyUnauthorizedAuthenticationError,
|
||||
)
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components import camera, image, media_source
|
||||
from homeassistant.components.notify import ATTR_MESSAGE, ATTR_TITLE
|
||||
from homeassistant.components.ntfy.const import DOMAIN
|
||||
from homeassistant.components.ntfy.notify import (
|
||||
ATTR_ATTACH,
|
||||
ATTR_ATTACH_FILE,
|
||||
ATTR_CALL,
|
||||
ATTR_CLICK,
|
||||
ATTR_DELAY,
|
||||
ATTR_EMAIL,
|
||||
ATTR_FILENAME,
|
||||
ATTR_ICON,
|
||||
ATTR_MARKDOWN,
|
||||
ATTR_PRIORITY,
|
||||
@@ -32,8 +36,9 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import AsyncMock, MockConfigEntry
|
||||
from tests.common import AsyncMock, MockConfigEntry, patch
|
||||
|
||||
|
||||
async def test_ntfy_publish(
|
||||
@@ -81,7 +86,8 @@ async def test_ntfy_publish(
|
||||
icon=URL("https://example.org/logo.png"),
|
||||
delay="86430.0s",
|
||||
sequence_id="Mc3otamDNcpJ",
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,21 +138,41 @@ async def test_send_message_exception(
|
||||
)
|
||||
|
||||
mock_aiontfy.publish.assert_called_once_with(
|
||||
Message(topic="mytopic", message="triggered", title="test")
|
||||
Message(topic="mytopic", message="triggered", title="test"), None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "error_msg"),
|
||||
("exception", "payload", "error_msg"),
|
||||
[
|
||||
(
|
||||
ServiceValidationError,
|
||||
{ATTR_DELAY: {"days": 1, "seconds": 30}, ATTR_CALL: "1234567890"},
|
||||
"Delayed call notifications are not supported",
|
||||
),
|
||||
(
|
||||
ServiceValidationError,
|
||||
{ATTR_DELAY: {"days": 1, "seconds": 30}, ATTR_EMAIL: "mail@example.org"},
|
||||
"Delayed email notifications are not supported",
|
||||
),
|
||||
(
|
||||
vol.MultipleInvalid,
|
||||
{
|
||||
ATTR_ATTACH: "https://example.com/Epic Sax Guy 10 Hours.mp4",
|
||||
ATTR_ATTACH_FILE: {
|
||||
"media_content_id": "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
|
||||
"media_content_type": "video/mp4",
|
||||
},
|
||||
},
|
||||
"Only one attachment source is allowed: URL or local file",
|
||||
),
|
||||
(
|
||||
vol.MultipleInvalid,
|
||||
{
|
||||
ATTR_FILENAME: "Epic Sax Guy 10 Hours.mp4",
|
||||
},
|
||||
"Filename only allowed when attachment is provided",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_send_message_validation_errors(
|
||||
@@ -155,16 +181,17 @@ async def test_send_message_validation_errors(
|
||||
mock_aiontfy: AsyncMock,
|
||||
payload: dict[str, Any],
|
||||
error_msg: str,
|
||||
exception: type[Exception],
|
||||
) -> None:
|
||||
"""Test publish message service validation errors."""
|
||||
|
||||
assert await async_setup_component(hass, "media_source", {})
|
||||
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
|
||||
|
||||
with pytest.raises(ServiceValidationError, match=error_msg):
|
||||
with pytest.raises(exception, match=error_msg):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PUBLISH,
|
||||
@@ -220,6 +247,149 @@ async def test_send_message_reauth_flow(
|
||||
assert flow["context"].get("entry_id") == config_entry.entry_id
|
||||
|
||||
|
||||
async def test_ntfy_publish_attachment_upload(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_aiontfy: AsyncMock,
|
||||
) -> None:
|
||||
"""Test publishing ntfy message via ntfy.publish action with attachment upload."""
|
||||
assert await async_setup_component(hass, "media_source", {})
|
||||
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(
|
||||
DOMAIN,
|
||||
SERVICE_PUBLISH,
|
||||
{
|
||||
ATTR_ENTITY_ID: "notify.mytopic",
|
||||
ATTR_ATTACH_FILE: {
|
||||
"media_content_id": "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
|
||||
"media_content_type": "video/mp4",
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_aiontfy.publish.assert_called_once_with(
|
||||
Message(topic="mytopic", filename="Epic Sax Guy 10 Hours.mp4"),
|
||||
b"I play the sax\n",
|
||||
)
|
||||
|
||||
|
||||
async def test_ntfy_publish_upload_camera_snapshot(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_aiontfy: AsyncMock,
|
||||
) -> None:
|
||||
"""Test publishing ntfy message via ntfy.publish action with camera snapshot upload."""
|
||||
|
||||
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
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.camera.async_get_image",
|
||||
return_value=camera.Image("image/jpeg", b"I play the sax\n"),
|
||||
) as mock_get_image,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PUBLISH,
|
||||
{
|
||||
ATTR_ENTITY_ID: "notify.mytopic",
|
||||
ATTR_ATTACH_FILE: {
|
||||
"media_content_id": "media-source://camera/camera.demo_camera",
|
||||
"media_content_type": "image/jpeg",
|
||||
},
|
||||
ATTR_FILENAME: "Epic Sax Guy 10 Hours.jpg",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_get_image.assert_called_once_with(hass, "camera.demo_camera")
|
||||
mock_aiontfy.publish.assert_called_once_with(
|
||||
Message(topic="mytopic", filename="Epic Sax Guy 10 Hours.jpg"),
|
||||
b"I play the sax\n",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_aiontfy")
|
||||
async def test_ntfy_publish_upload_media_source_not_supported(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test publishing ntfy message via ntfy.publish action with unsupported media source."""
|
||||
|
||||
assert await async_setup_component(hass, "tts", {})
|
||||
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
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.ntfy.notify.async_resolve_media",
|
||||
return_value=media_source.PlayMedia(
|
||||
url="/api/tts_proxy/WDyphPCh3sAoO3koDY87ew.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
path=None,
|
||||
),
|
||||
),
|
||||
pytest.raises(
|
||||
ServiceValidationError,
|
||||
match="Media source currently not supported",
|
||||
),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PUBLISH,
|
||||
{
|
||||
ATTR_ENTITY_ID: "notify.mytopic",
|
||||
ATTR_ATTACH_FILE: {
|
||||
"media_content_id": "media-source://tts/demo?message=Hello+world%21&language=en",
|
||||
"media_content_type": "audio/mp3",
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_aiontfy")
|
||||
async def test_ntfy_publish_upload_media_image_source(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_aiontfy: AsyncMock,
|
||||
) -> None:
|
||||
"""Test publishing ntfy message with image source."""
|
||||
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
|
||||
with patch(
|
||||
"homeassistant.components.image.async_get_image",
|
||||
return_value=image.Image(content_type="image/jpeg", content=b"\x89PNG"),
|
||||
) as mock_get_image:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PUBLISH,
|
||||
{
|
||||
ATTR_ENTITY_ID: "notify.mytopic",
|
||||
ATTR_ATTACH_FILE: {
|
||||
"media_content_id": "media-source://image/image.test",
|
||||
"media_content_type": "image/png",
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_get_image.assert_called_once_with(hass, "image.test")
|
||||
mock_aiontfy.publish.assert_called_once_with(Message(topic="mytopic"), b"\x89PNG")
|
||||
|
||||
|
||||
async def test_ntfy_clear(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
|
||||
Reference in New Issue
Block a user