diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index 9c919154554..7f78206302e 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -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( diff --git a/homeassistant/components/ntfy/services.yaml b/homeassistant/components/ntfy/services.yaml index b4b2933847f..d6664b70f5b 100644 --- a/homeassistant/components/ntfy/services.yaml +++ b/homeassistant/components/ntfy/services.yaml @@ -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: diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index d6c6c01fd61..ce6f385f95b 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -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" diff --git a/tests/components/ntfy/test_notify.py b/tests/components/ntfy/test_notify.py index bf8131574bf..af138f841d5 100644 --- a/tests/components/ntfy/test_notify.py +++ b/tests/components/ntfy/test_notify.py @@ -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 ) diff --git a/tests/components/ntfy/test_services.py b/tests/components/ntfy/test_services.py index cd9b78f05aa..0e1ca6fc029 100644 --- a/tests/components/ntfy/test_services.py +++ b/tests/components/ntfy/test_services.py @@ -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,