1
0
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:
Manu
2026-02-10 13:47:16 +01:00
committed by GitHub
parent 2c9a96b62a
commit 63bbead41e
5 changed files with 272 additions and 28 deletions

View File

@@ -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(

View File

@@ -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:

View File

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

View File

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

View File

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