diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py index 72dbb4d2afb0..ccaf50ebef1d 100644 --- a/homeassistant/components/ntfy/__init__.py +++ b/homeassistant/components/ntfy/__init__.py @@ -21,7 +21,7 @@ from .const import DOMAIN from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.NOTIFY, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py index ba409070c760..c817cf4ba367 100644 --- a/homeassistant/components/ntfy/config_flow.py +++ b/homeassistant/components/ntfy/config_flow.py @@ -38,12 +38,25 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, TextSelector, TextSelectorConfig, TextSelectorType, ) -from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN, SECTION_AUTH +from .const import ( + CONF_MESSAGE, + CONF_PRIORITY, + CONF_TAGS, + CONF_TITLE, + CONF_TOPIC, + DEFAULT_URL, + DOMAIN, + SECTION_AUTH, + SECTION_FILTER, +) _LOGGER = logging.getLogger(__name__) @@ -112,6 +125,29 @@ STEP_USER_TOPIC_SCHEMA = vol.Schema( { vol.Required(CONF_TOPIC): str, vol.Optional(CONF_NAME): str, + vol.Required(SECTION_FILTER): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_PRIORITY): SelectSelector( + SelectSelectorConfig( + multiple=True, + options=["5", "4", "3", "2", "1"], + mode=SelectSelectorMode.DROPDOWN, + translation_key="priority", + ) + ), + vol.Optional(CONF_TAGS): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + multiple=True, + ), + ), + vol.Optional(CONF_TITLE): str, + vol.Optional(CONF_MESSAGE): str, + } + ), + {"collapsed": True}, + ), } ) @@ -408,7 +444,10 @@ class TopicSubentryFlowHandler(ConfigSubentryFlow): return self.async_create_entry( title=user_input.get(CONF_NAME, user_input[CONF_TOPIC]), - data={CONF_TOPIC: user_input[CONF_TOPIC]}, + data={ + CONF_TOPIC: user_input[CONF_TOPIC], + **user_input[SECTION_FILTER], + }, unique_id=user_input[CONF_TOPIC], ) return self.async_show_form( diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py index 78355f7e828d..5fb500917d67 100644 --- a/homeassistant/components/ntfy/const.py +++ b/homeassistant/components/ntfy/const.py @@ -6,4 +6,10 @@ DOMAIN = "ntfy" DEFAULT_URL: Final = "https://ntfy.sh" CONF_TOPIC = "topic" +CONF_PRIORITY = "filter_priority" +CONF_TITLE = "filter_title" +CONF_MESSAGE = "filter_message" +CONF_TAGS = "filter_tags" SECTION_AUTH = "auth" +SECTION_FILTER = "filter" +NTFY_EVENT = "ntfy_event" diff --git a/homeassistant/components/ntfy/entity.py b/homeassistant/components/ntfy/entity.py new file mode 100644 index 000000000000..d03d953799f0 --- /dev/null +++ b/homeassistant/components/ntfy/entity.py @@ -0,0 +1,43 @@ +"""Base entity for ntfy integration.""" + +from __future__ import annotations + +from yarl import URL + +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_URL +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import CONF_TOPIC, DOMAIN +from .coordinator import NtfyConfigEntry + + +class NtfyBaseEntity(Entity): + """Base entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + config_entry: NtfyConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the entity.""" + self.topic = subentry.data[CONF_TOPIC] + + self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + name=subentry.title, + configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, + identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, + via_device=(DOMAIN, config_entry.entry_id), + ) + self.ntfy = config_entry.runtime_data.ntfy + self.config_entry = config_entry + self.subentry = subentry diff --git a/homeassistant/components/ntfy/event.py b/homeassistant/components/ntfy/event.py new file mode 100644 index 000000000000..d961b67dcb85 --- /dev/null +++ b/homeassistant/components/ntfy/event.py @@ -0,0 +1,151 @@ +"""Event platform for ntfy integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from aiontfy import Event, Notification +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyForbiddenError, + NtfyHTTPError, + NtfyTimeoutError, +) + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_MESSAGE, CONF_PRIORITY, CONF_TAGS, CONF_TITLE +from .coordinator import NtfyConfigEntry +from .entity import NtfyBaseEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 +RECONNECT_INTERVAL = 10 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the event platform.""" + + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [NtfyEventEntity(config_entry, subentry)], config_subentry_id=subentry_id + ) + + +class NtfyEventEntity(NtfyBaseEntity, EventEntity): + """An event entity.""" + + entity_description = EventEntityDescription( + key="subscribe", + translation_key="subscribe", + name=None, + event_types=["triggered"], + ) + + def __init__( + self, + config_entry: NtfyConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the entity.""" + super().__init__(config_entry, subentry) + self._ws: asyncio.Task | None = None + + @callback + def _async_handle_event(self, notification: Notification) -> None: + """Handle the ntfy event.""" + if notification.topic == self.topic and notification.event is Event.MESSAGE: + event = ( + f"{notification.title}: {notification.message}" + if notification.title + else notification.message + ) + if TYPE_CHECKING: + assert event + self._attr_event_types = [event] + self._trigger_event(event, notification.to_dict()) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + self.config_entry.async_create_background_task( + self.hass, + self.ws_connect(), + "websocket_watchdog", + ) + + async def ws_connect(self) -> None: + """Connect websocket.""" + while True: + try: + if self._ws and (exc := self._ws.exception()): + raise exc # noqa: TRY301 + except asyncio.InvalidStateError: + self._attr_available = True + except asyncio.CancelledError: + self._attr_available = False + return + except NtfyForbiddenError: + if self._attr_available: + _LOGGER.error("Failed to subscribe to topic. Topic is protected") + self._attr_available = False + return + except NtfyHTTPError as e: + if self._attr_available: + _LOGGER.error( + "Failed to connect to ntfy service due to a server error: %s (%s)", + e.error, + e.link, + ) + self._attr_available = False + except NtfyConnectionError: + if self._attr_available: + _LOGGER.error( + "Failed to connect to ntfy service due to a connection error" + ) + self._attr_available = False + except NtfyTimeoutError: + if self._attr_available: + _LOGGER.error( + "Failed to connect to ntfy service due to a connection timeout" + ) + self._attr_available = False + except Exception: + if self._attr_available: + _LOGGER.exception( + "Failed to connect to ntfy service due to an unexpected exception" + ) + self._attr_available = False + finally: + if self._ws is None or self._ws.done(): + self._ws = self.config_entry.async_create_background_task( + self.hass, + target=self.ntfy.subscribe( + topics=[self.topic], + callback=self._async_handle_event, + title=self.subentry.data.get(CONF_TITLE), + message=self.subentry.data.get(CONF_MESSAGE), + priority=self.subentry.data.get(CONF_PRIORITY), + tags=self.subentry.data.get(CONF_TAGS), + ), + name="ntfy_websocket", + ) + self.async_write_ha_state() + await asyncio.sleep(RECONNECT_INTERVAL) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + + return self.state_attributes.get("icon") or super().entity_picture diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json index 66489413b5b3..6c8a419e5c9b 100644 --- a/homeassistant/components/ntfy/icons.json +++ b/homeassistant/components/ntfy/icons.json @@ -5,6 +5,11 @@ "default": "mdi:console-line" } }, + "event": { + "subscribe": { + "default": "mdi:message-outline" + } + }, "sensor": { "messages": { "default": "mdi:message-arrow-right-outline" diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index 214e3d7e125c..40f8b0a68ad3 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -8,22 +8,19 @@ from aiontfy.exceptions import ( NtfyHTTPError, NtfyUnauthorizedAuthenticationError, ) -from yarl import URL from homeassistant.components.notify import ( NotifyEntity, NotifyEntityDescription, NotifyEntityFeature, ) -from homeassistant.config_entries import ConfigSubentry -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_TOPIC, DOMAIN +from .const import DOMAIN from .coordinator import NtfyConfigEntry +from .entity import NtfyBaseEntity PARALLEL_UPDATES = 0 @@ -41,39 +38,16 @@ async def async_setup_entry( ) -class NtfyNotifyEntity(NotifyEntity): +class NtfyNotifyEntity(NtfyBaseEntity, NotifyEntity): """Representation of a ntfy notification entity.""" entity_description = NotifyEntityDescription( key="publish", translation_key="publish", name=None, - has_entity_name=True, ) _attr_supported_features = NotifyEntityFeature.TITLE - def __init__( - self, - config_entry: NtfyConfigEntry, - subentry: ConfigSubentry, - ) -> None: - """Initialize a notification entity.""" - - self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}" - self.topic = subentry.data[CONF_TOPIC] - - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - manufacturer="ntfy LLC", - model="ntfy", - name=subentry.title, - configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, - identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, - via_device=(DOMAIN, config_entry.entry_id), - ) - self.config_entry = config_entry - self.ntfy = config_entry.runtime_data.ntfy - async def async_send_message(self, message: str, title: str | None = None) -> None: """Publish a message to a topic.""" msg = Message(topic=self.topic, message=message, title=title) diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml index 43a96135baf3..2102228d7a21 100644 --- a/homeassistant/components/ntfy/quality_scale.yaml +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -7,9 +7,7 @@ rules: status: exempt comment: the integration does not poll brands: done - common-modules: - status: exempt - comment: the integration currently implements only one platform and has no coordinator + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done @@ -19,9 +17,7 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: the integration does not subscribe to events + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done @@ -36,13 +32,9 @@ rules: status: exempt comment: the integration has no options docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: the integration only implements a stateless notify entity. + entity-unavailable: done integration-owner: done - log-when-unavailable: - status: exempt - comment: the integration only integrates state-less entities + log-when-unavailable: done parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 08a0a20a30a1..156029cfc76a 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -102,6 +102,24 @@ "data_description": { "topic": "Enter the name of the topic you want to use for notifications. Topics may not be password-protected, so choose a name that's not easy to guess.", "name": "Set an alternative name to display instead of the topic name. This helps identify topics with complex or hard-to-read names more easily." + }, + "sections": { + "filter": { + "data": { + "filter_priority": "Filter by priority", + "filter_tags": "Filter by tags", + "filter_title": "Filter by title", + "filter_message": "Filter by message content" + }, + "data_description": { + "filter_priority": "Include messages that match any of the selected priority levels. If no priority is selected, all messages are included by default", + "filter_tags": "Only include messages that have all selected tags", + "filter_title": "Include messages with a title that exactly matches the specified text", + "filter_message": "Include messages with content that exactly matches the specified text" + }, + "name": "Message filters (optional)", + "description": "Apply filters to narrow down the messages received when Home Assistant subscribes to the topic. Filters apply only to the event entity." + } } } }, @@ -121,6 +139,17 @@ } }, "entity": { + "event": { + "subscribe": { + "state_attributes": { + "event_type": { + "state": { + "triggered": "Triggered" + } + } + } + } + }, "sensor": { "messages": { "name": "Messages published", @@ -222,5 +251,16 @@ "timeout_error": { "message": "Failed to connect to ntfy service due to a connection timeout" } + }, + "selector": { + "priority": { + "options": { + "1": "Minimum", + "2": "[%key:common::state::low%]", + "3": "Default", + "4": "[%key:common::state::high%]", + "5": "Maximum" + } + } } } diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py index d9bc620b464a..91e2e1ee5f8c 100644 --- a/tests/components/ntfy/conftest.py +++ b/tests/components/ntfy/conftest.py @@ -1,10 +1,11 @@ """Common fixtures for the ntfy tests.""" -from collections.abc import Generator -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +from collections.abc import Callable, Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, Mock, patch -from aiontfy import Account, AccountTokenResponse +from aiontfy import Account, AccountTokenResponse, Event, Notification import pytest from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN @@ -40,6 +41,50 @@ def mock_aiontfy() -> Generator[AsyncMock]: client.generate_token.return_value = AccountTokenResponse( token="token", last_access=datetime.now() ) + + resp = Mock( + id="h6Y2hKA5sy0U", + time=datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC), + expires=datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC), + event=Event.MESSAGE, + topic="mytopic", + message="Hello", + title="Title", + tags=["octopus"], + priority=3, + click="https://example.com/", + icon="https://example.com/icon.png", + actions=[], + attachment=None, + content_type=None, + ) + + resp.to_dict.return_value = { + "id": "h6Y2hKA5sy0U", + "time": datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC), + "expires": datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC), + "event": Event.MESSAGE, + "topic": "mytopic", + "message": "Hello", + "title": "Title", + "tags": ["octopus"], + "priority": 3, + "click": "https://example.com/", + "icon": "https://example.com/icon.png", + "actions": [], + "attachment": None, + "content_type": None, + } + + async def mock_ws( + topics: list[str], callback: Callable[[Notification], None], **kwargs + ): + callback(resp) + while True: + await asyncio.sleep(1) + + client.subscribe.side_effect = mock_ws + yield client diff --git a/tests/components/ntfy/snapshots/test_event.ambr b/tests/components/ntfy/snapshots/test_event.ambr new file mode 100644 index 000000000000..ed6095f0888e --- /dev/null +++ b/tests/components/ntfy/snapshots/test_event.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_event_platform[event.mytopic-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'Title: Hello', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.mytopic', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'subscribe', + 'unique_id': '123456789_ABCDEF_subscribe', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_platform[event.mytopic-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'actions': list([ + ]), + 'attachment': None, + 'click': 'https://example.com/', + 'content_type': None, + 'entity_picture': 'https://example.com/icon.png', + 'event': , + 'event_type': 'Title: Hello', + 'event_types': list([ + 'Title: Hello', + ]), + 'expires': datetime.datetime(2025, 3, 29, 5, 58, 46, tzinfo=datetime.timezone.utc), + 'friendly_name': 'mytopic', + 'icon': 'https://example.com/icon.png', + 'id': 'h6Y2hKA5sy0U', + 'message': 'Hello', + 'priority': 3, + 'tags': list([ + 'octopus', + ]), + 'time': datetime.datetime(2025, 3, 28, 17, 58, 46, tzinfo=datetime.timezone.utc), + 'title': 'Title', + 'topic': 'mytopic', + }), + 'context': , + 'entity_id': 'event.mytopic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-09-03T22:00:00.000+00:00', + }) +# --- diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 0bc488337024..e38ea26d9822 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -12,7 +12,12 @@ from aiontfy.exceptions import ( ) import pytest -from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN, SECTION_AUTH +from homeassistant.components.ntfy.const import ( + CONF_TOPIC, + DOMAIN, + SECTION_AUTH, + SECTION_FILTER, +) from homeassistant.config_entries import SOURCE_USER, ConfigSubentry from homeassistant.const import ( CONF_NAME, @@ -204,7 +209,10 @@ async def test_add_topic_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "mytopic"}, + user_input={ + CONF_TOPIC: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY subentry_id = list(config_entry.subentries)[0] @@ -252,14 +260,21 @@ async def test_generated_topic(hass: HomeAssistant, mock_random: AsyncMock) -> N result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: ""}, + user_input={ + CONF_TOPIC: "", + SECTION_FILTER: {}, + }, ) mock_random.assert_called_once() result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + user_input={ + CONF_TOPIC: "randomtopic", + CONF_NAME: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -306,7 +321,10 @@ async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> Non result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "invalid,topic"}, + user_input={ + CONF_TOPIC: "invalid,topic", + SECTION_FILTER: {}, + }, ) assert result["type"] == FlowResultType.FORM @@ -314,7 +332,10 @@ async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> Non result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "mytopic"}, + user_input={ + CONF_TOPIC: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -360,7 +381,10 @@ async def test_topic_already_configured( result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "mytopic"}, + user_input={ + CONF_TOPIC: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/ntfy/test_event.py b/tests/components/ntfy/test_event.py new file mode 100644 index 000000000000..92e01b1ba2c3 --- /dev/null +++ b/tests/components/ntfy/test_event.py @@ -0,0 +1,158 @@ +"""Tests for the ntfy event platform.""" + +import asyncio +from collections.abc import AsyncGenerator +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, patch + +from aiontfy import Event +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyForbiddenError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) +from freezegun.api import FrozenDateTimeFactory, freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +async def event_only() -> AsyncGenerator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.EVENT], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy") +@freeze_time("2025-09-03T22:00:00.000Z") +async def test_event_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the ntfy event platform.""" + + 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 snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_event( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test ntfy events.""" + + 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 + + assert (state := hass.states.get("event.mytopic")) + assert state.state != STATE_UNKNOWN + + assert state.attributes == { + "actions": [], + "attachment": None, + "click": "https://example.com/", + "content_type": None, + "entity_picture": "https://example.com/icon.png", + "event": Event.MESSAGE, + "event_type": "Title: Hello", + "event_types": [ + "Title: Hello", + ], + "expires": datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC), + "friendly_name": "mytopic", + "icon": "https://example.com/icon.png", + "id": "h6Y2hKA5sy0U", + "message": "Hello", + "priority": 3, + "tags": [ + "octopus", + ], + "time": datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC), + "title": "Title", + "topic": "mytopic", + } + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + ( + NtfyHTTPError(41801, 418, "I'm a teapot", ""), + STATE_UNAVAILABLE, + ), + ( + NtfyConnectionError, + STATE_UNAVAILABLE, + ), + ( + NtfyTimeoutError, + STATE_UNAVAILABLE, + ), + ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + STATE_UNAVAILABLE, + ), + ( + NtfyForbiddenError(403, 403, "forbidden"), + STATE_UNAVAILABLE, + ), + ( + asyncio.CancelledError, + STATE_UNAVAILABLE, + ), + ( + asyncio.InvalidStateError, + STATE_UNKNOWN, + ), + ( + ValueError, + STATE_UNAVAILABLE, + ), + ], +) +async def test_event_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + freezer: FrozenDateTimeFactory, + exception: Exception, + expected_state: str, +) -> None: + """Test ntfy events exceptions.""" + mock_aiontfy.subscribe.side_effect = exception + + 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 + + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("event.mytopic")) + assert state.state == expected_state