Add ntfy (ntfy.sh) integration (#135152)

Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
Manu
2025-04-23 14:52:13 +02:00
committed by GitHub
parent 8a2347539c
commit 3c174ce329
21 changed files with 1297 additions and 0 deletions

View File

@ -363,6 +363,7 @@ homeassistant.components.no_ip.*
homeassistant.components.nordpool.*
homeassistant.components.notify.*
homeassistant.components.notion.*
homeassistant.components.ntfy.*
homeassistant.components.number.*
homeassistant.components.nut.*
homeassistant.components.ohme.*

2
CODEOWNERS generated
View File

@ -1051,6 +1051,8 @@ build.json @home-assistant/supervisor
/tests/components/nsw_fuel_station/ @nickw444
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
/tests/components/nsw_rural_fire_service_feed/ @exxamalte
/homeassistant/components/ntfy/ @tr4nt0r
/tests/components/ntfy/ @tr4nt0r
/homeassistant/components/nuheat/ @tstabrawa
/tests/components/nuheat/ @tstabrawa
/homeassistant/components/nuki/ @pschmitt @pvizeli @pree

View File

@ -0,0 +1,78 @@
"""The ntfy integration."""
from __future__ import annotations
import logging
from aiontfy import Ntfy
from aiontfy.exceptions import (
NtfyConnectionError,
NtfyHTTPError,
NtfyTimeoutError,
NtfyUnauthorizedAuthenticationError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.NOTIFY]
type NtfyConfigEntry = ConfigEntry[Ntfy]
async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool:
"""Set up ntfy from a config entry."""
session = async_get_clientsession(hass)
ntfy = Ntfy(entry.data[CONF_URL], session, token=entry.data.get(CONF_TOKEN))
try:
await ntfy.account()
except NtfyUnauthorizedAuthenticationError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from e
except NtfyHTTPError as e:
_LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="server_error",
translation_placeholders={"error_msg": str(e.error)},
) from e
except NtfyConnectionError as e:
_LOGGER.debug("Error", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_error",
) from e
except NtfyTimeoutError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_error",
) from e
entry.runtime_data = ntfy
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,216 @@
"""Config flow for the ntfy integration."""
from __future__ import annotations
import logging
import random
import re
import string
from typing import Any
from aiontfy import Ntfy
from aiontfy.exceptions import (
NtfyException,
NtfyHTTPError,
NtfyUnauthorizedAuthenticationError,
)
import voluptuous as vol
from yarl import URL
from homeassistant import data_entry_flow
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN, SECTION_AUTH
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL, default=DEFAULT_URL): TextSelector(
TextSelectorConfig(
type=TextSelectorType.URL,
autocomplete="url",
),
),
vol.Required(SECTION_AUTH): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_USERNAME): TextSelector(
TextSelectorConfig(
type=TextSelectorType.TEXT,
autocomplete="username",
),
),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
),
),
}
),
{"collapsed": True},
),
}
)
STEP_USER_TOPIC_SCHEMA = vol.Schema(
{
vol.Required(CONF_TOPIC): str,
vol.Optional(CONF_NAME): str,
}
)
RE_TOPIC = re.compile("^[-_a-zA-Z0-9]{1,64}$")
class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for ntfy."""
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"topic": TopicSubentryFlowHandler}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
url = URL(user_input[CONF_URL])
username = user_input[SECTION_AUTH].get(CONF_USERNAME)
self._async_abort_entries_match(
{
CONF_URL: url.human_repr(),
CONF_USERNAME: username,
}
)
session = async_get_clientsession(self.hass)
if username:
ntfy = Ntfy(
user_input[CONF_URL],
session,
username,
user_input[SECTION_AUTH].get(CONF_PASSWORD, ""),
)
else:
ntfy = Ntfy(user_input[CONF_URL], session)
try:
account = await ntfy.account()
token = (
(await ntfy.generate_token("Home Assistant")).token
if account.username != "*"
else None
)
except NtfyUnauthorizedAuthenticationError:
errors["base"] = "invalid_auth"
except NtfyHTTPError as e:
_LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link)
errors["base"] = "cannot_connect"
except NtfyException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=url.host or "",
data={
CONF_URL: url.human_repr(),
CONF_USERNAME: username,
CONF_TOKEN: token,
},
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
)
class TopicSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow for adding and modifying a topic."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to add a new topic."""
return self.async_show_menu(
step_id="user",
menu_options=["add_topic", "generate_topic"],
)
async def async_step_generate_topic(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to add a new topic."""
topic = "".join(
random.choices(
string.ascii_lowercase + string.ascii_uppercase + string.digits,
k=16,
)
)
return self.async_show_form(
step_id="add_topic",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_TOPIC_SCHEMA,
suggested_values={CONF_TOPIC: topic},
),
)
async def async_step_add_topic(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to add a new topic."""
config_entry = self._get_entry()
errors: dict[str, str] = {}
if user_input is not None:
if not RE_TOPIC.match(user_input[CONF_TOPIC]):
errors["base"] = "invalid_topic"
else:
for existing_subentry in config_entry.subentries.values():
if existing_subentry.unique_id == user_input[CONF_TOPIC]:
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=user_input.get(CONF_NAME, user_input[CONF_TOPIC]),
data=user_input,
unique_id=user_input[CONF_TOPIC],
)
return self.async_show_form(
step_id="add_topic",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_TOPIC_SCHEMA, suggested_values=user_input
),
errors=errors,
)

View File

@ -0,0 +1,9 @@
"""Constants for the ntfy integration."""
from typing import Final
DOMAIN = "ntfy"
DEFAULT_URL: Final = "https://ntfy.sh"
CONF_TOPIC = "topic"
SECTION_AUTH = "auth"

View File

@ -0,0 +1,9 @@
{
"entity": {
"notify": {
"publish": {
"default": "mdi:console-line"
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"domain": "ntfy",
"name": "ntfy",
"codeowners": ["@tr4nt0r"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ntfy",
"iot_class": "cloud_push",
"loggers": ["aionfty"],
"quality_scale": "bronze",
"requirements": ["aiontfy==0.5.1"]
}

View File

@ -0,0 +1,86 @@
"""ntfy notification entity."""
from __future__ import annotations
from aiontfy import Message
from aiontfy.exceptions import NtfyException, NtfyHTTPError
from yarl import URL
from homeassistant.components.notify import (
NotifyEntity,
NotifyEntityDescription,
NotifyEntityFeature,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_NAME, 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 . import NtfyConfigEntry
from .const import CONF_TOPIC, DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NtfyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ntfy notification entity platform."""
for subentry_id, subentry in config_entry.subentries.items():
async_add_entities(
[NtfyNotifyEntity(config_entry, subentry)], config_subentry_id=subentry_id
)
class NtfyNotifyEntity(NotifyEntity):
"""Representation of a ntfy notification entity."""
entity_description = NotifyEntityDescription(
key="publish",
translation_key="publish",
name=None,
has_entity_name=True,
)
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_supported_features = NotifyEntityFeature.TITLE
self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer="ntfy LLC",
model="ntfy",
name=subentry.data.get(CONF_NAME, self.topic),
configuration_url=URL(config_entry.data[CONF_URL]) / self.topic,
identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")},
)
self.ntfy = config_entry.runtime_data
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)
try:
await self.ntfy.publish(msg)
except NtfyHTTPError as e:
raise HomeAssistantError(
translation_key="publish_failed_request_error",
translation_domain=DOMAIN,
translation_placeholders={"error_msg": e.error},
) from e
except NtfyException as e:
raise HomeAssistantError(
translation_key="publish_failed_exception",
translation_domain=DOMAIN,
) from e

View File

@ -0,0 +1,84 @@
rules:
# Bronze
action-setup:
status: exempt
comment: only entity actions
appropriate-polling:
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
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
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-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
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.
integration-owner: done
log-when-unavailable:
status: exempt
comment: the integration only integrates state-less entities
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class:
status: exempt
comment: no suitable device class for the notify entity
entity-disabled-by-default:
status: exempt
comment: only one entity
entity-translations:
status: exempt
comment: the notify entity uses the topic as name, no translation required
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: the integration has no repeairs
stale-devices:
status: exempt
comment: only one device per entry, is deleted with the entry.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@ -0,0 +1,101 @@
{
"common": {
"topic": "Topic",
"add_topic_description": "Set up a topic for notifications."
},
"config": {
"step": {
"user": {
"description": "Set up **ntfy** push notification service",
"data": {
"url": "Service URL"
},
"data_description": {
"url": "Address of the ntfy service. Modify this if you want to use a different server"
},
"sections": {
"auth": {
"name": "Authentication",
"description": "Depending on whether the server is configured to support access control, some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. To publish/subscribe to protected topics, you can provide a username and password.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "Enter the username required to authenticate with protected ntfy topics",
"password": "Enter the password corresponding to the provided username for authentication"
}
}
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"config_subentries": {
"topic": {
"step": {
"user": {
"title": "[%key:component::ntfy::common::topic%]",
"description": "[%key:component::ntfy::common::add_topic_description%]",
"menu_options": {
"add_topic": "Enter topic",
"generate_topic": "Generate topic name"
}
},
"add_topic": {
"title": "[%key:component::ntfy::common::topic%]",
"description": "[%key:component::ntfy::common::add_topic_description%]",
"data": {
"topic": "[%key:component::ntfy::common::topic%]",
"name": "Display name"
},
"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."
}
}
},
"initiate_flow": {
"user": "Add topic"
},
"entry_type": "[%key:component::ntfy::common::topic%]",
"error": {
"publish_forbidden": "Publishing to this topic is forbidden",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "Topic is already configured",
"invalid_topic": "Invalid topic. Only letters, numbers, underscores, or dashes allowed."
}
}
},
"exceptions": {
"publish_failed_request_error": {
"message": "Failed to publish notification: {error_msg}"
},
"publish_failed_exception": {
"message": "Failed to publish notification due to a connection error"
},
"authentication_error": {
"message": "Failed to authenticate with ntfy service. Please verify your credentials"
},
"server_error": {
"message": "Failed to connect to ntfy service due to a server error: {error_msg}"
},
"connection_error": {
"message": "Failed to connect to ntfy service due to a connection error"
},
"timeout_error": {
"message": "Failed to connect to ntfy service due to a connection timeout"
}
}
}

View File

@ -428,6 +428,7 @@ FLOWS = {
"nobo_hub",
"nordpool",
"notion",
"ntfy",
"nuheat",
"nuki",
"nut",

View File

@ -4430,6 +4430,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"ntfy": {
"name": "ntfy",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
"nuheat": {
"name": "NuHeat",
"integration_type": "hub",

10
mypy.ini generated
View File

@ -3386,6 +3386,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.ntfy.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.number.*]
check_untyped_defs = true
disallow_incomplete_defs = true

3
requirements_all.txt generated
View File

@ -314,6 +314,9 @@ aionanoleaf==0.2.1
# homeassistant.components.notion
aionotion==2024.03.0
# homeassistant.components.ntfy
aiontfy==0.5.1
# homeassistant.components.nut
aionut==4.3.4

View File

@ -296,6 +296,9 @@ aionanoleaf==0.2.1
# homeassistant.components.notion
aionotion==2024.03.0
# homeassistant.components.ntfy
aiontfy==0.5.1
# homeassistant.components.nut
aionut==4.3.4

View File

@ -0,0 +1 @@
"""Tests for ntfy integration."""

View File

@ -0,0 +1,75 @@
"""Common fixtures for the ntfy tests."""
from collections.abc import Generator
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from aiontfy import AccountTokenResponse
import pytest
from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN
from homeassistant.config_entries import ConfigSubentryData
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.ntfy.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_aiontfy() -> Generator[AsyncMock]:
"""Mock aiontfy."""
with (
patch("homeassistant.components.ntfy.Ntfy", autospec=True) as mock_client,
patch("homeassistant.components.ntfy.config_flow.Ntfy", new=mock_client),
):
client = mock_client.return_value
client.publish.return_value = {}
client.generate_token.return_value = AccountTokenResponse(
token="token", last_access=datetime.now()
)
yield client
@pytest.fixture(autouse=True)
def mock_random() -> Generator[MagicMock]:
"""Mock random."""
with patch(
"homeassistant.components.ntfy.config_flow.random.choices",
return_value=["randomtopic"],
) as mock_client:
yield mock_client
@pytest.fixture(name="config_entry")
def mock_config_entry() -> MockConfigEntry:
"""Mock ntfy configuration entry."""
return MockConfigEntry(
domain=DOMAIN,
title="ntfy.sh",
data={
CONF_URL: "https://ntfy.sh/",
CONF_USERNAME: None,
CONF_TOKEN: "token",
},
entry_id="123456789",
subentries_data=[
ConfigSubentryData(
data={CONF_TOPIC: "mytopic"},
subentry_id="ABCDEF",
subentry_type="topic",
title="mytopic",
unique_id="mytopic",
)
],
)

View File

@ -0,0 +1,49 @@
# serializer version: 1
# name: test_notify_platform[notify.mytopic-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'notify',
'entity_category': None,
'entity_id': 'notify.mytopic',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'ntfy',
'previous_unique_id': None,
'supported_features': <NotifyEntityFeature: 1>,
'translation_key': 'publish',
'unique_id': '123456789_ABCDEF_publish',
'unit_of_measurement': None,
})
# ---
# name: test_notify_platform[notify.mytopic-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'mytopic',
'supported_features': <NotifyEntityFeature: 1>,
}),
'context': <ANY>,
'entity_id': 'notify.mytopic',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -0,0 +1,355 @@
"""Test the ntfy config flow."""
from typing import Any
from unittest.mock import AsyncMock, patch
from aiontfy.exceptions import (
NtfyException,
NtfyHTTPError,
NtfyUnauthorizedAuthenticationError,
)
import pytest
from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN, SECTION_AUTH
from homeassistant.config_entries import SOURCE_USER, ConfigSubentry
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("user_input", "entry_data"),
[
(
{
CONF_URL: "https://ntfy.sh",
SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
},
{
CONF_URL: "https://ntfy.sh/",
CONF_USERNAME: "username",
CONF_TOKEN: "token",
},
),
(
{CONF_URL: "https://ntfy.sh", SECTION_AUTH: {}},
{CONF_URL: "https://ntfy.sh/", CONF_USERNAME: None, CONF_TOKEN: "token"},
),
],
)
@pytest.mark.usefixtures("mock_aiontfy")
async def test_form(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
user_input: dict[str, Any],
entry_data: dict[str, Any],
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.ntfy.config_flow.Ntfy.publish",
return_value=True,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "ntfy.sh"
assert result["data"] == entry_data
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(
NtfyHTTPError(418001, 418, "I'm a teapot", ""),
"cannot_connect",
),
(
NtfyUnauthorizedAuthenticationError(
40101,
401,
"unauthorized",
"https://ntfy.sh/docs/publish/#authentication",
),
"invalid_auth",
),
(NtfyException, "cannot_connect"),
(TypeError, "unknown"),
],
)
async def test_form_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_aiontfy: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_aiontfy.account.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: "https://ntfy.sh",
SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_aiontfy.account.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: "https://ntfy.sh",
SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "ntfy.sh"
assert result["data"] == {
CONF_URL: "https://ntfy.sh/",
CONF_USERNAME: "username",
CONF_TOKEN: "token",
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_aiontfy")
async def test_form_already_configured(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test we abort when entry is already configured."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_URL: "https://ntfy.sh", SECTION_AUTH: {}},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_aiontfy")
async def test_add_topic_flow(hass: HomeAssistant) -> None:
"""Test add topic subentry flow."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_URL: "https://ntfy.sh/", CONF_USERNAME: None},
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "topic"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.MENU
assert "add_topic" in result["menu_options"]
assert result["step_id"] == "user"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{"next_step_id": "add_topic"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "add_topic"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={CONF_TOPIC: "mytopic"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
subentry_id = list(config_entry.subentries)[0]
assert config_entry.subentries == {
subentry_id: ConfigSubentry(
data={CONF_TOPIC: "mytopic"},
subentry_id=subentry_id,
subentry_type="topic",
title="mytopic",
unique_id="mytopic",
)
}
await hass.async_block_till_done()
@pytest.mark.usefixtures("mock_aiontfy")
async def test_generated_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None:
"""Test add topic subentry flow with generated topic name."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_URL: "https://ntfy.sh/"},
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "topic"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.MENU
assert "generate_topic" in result["menu_options"]
assert result["step_id"] == "user"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{"next_step_id": "generate_topic"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "add_topic"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={CONF_TOPIC: ""},
)
mock_random.assert_called_once()
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
subentry_id = list(config_entry.subentries)[0]
assert config_entry.subentries == {
subentry_id: ConfigSubentry(
data={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"},
subentry_id=subentry_id,
subentry_type="topic",
title="mytopic",
unique_id="randomtopic",
)
}
@pytest.mark.usefixtures("mock_aiontfy")
async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None:
"""Test add topic subentry flow with invalid topic name."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_URL: "https://ntfy.sh/"},
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "topic"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.MENU
assert "add_topic" in result["menu_options"]
assert result["step_id"] == "user"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{"next_step_id": "add_topic"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "add_topic"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={CONF_TOPIC: "invalid,topic"},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "invalid_topic"}
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={CONF_TOPIC: "mytopic"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
subentry_id = list(config_entry.subentries)[0]
assert config_entry.subentries == {
subentry_id: ConfigSubentry(
data={CONF_TOPIC: "mytopic"},
subentry_id=subentry_id,
subentry_type="topic",
title="mytopic",
unique_id="mytopic",
)
}
@pytest.mark.usefixtures("mock_aiontfy")
async def test_topic_already_configured(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test we abort when entry is already configured."""
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "topic"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.MENU
assert "add_topic" in result["menu_options"]
assert result["step_id"] == "user"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{"next_step_id": "add_topic"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "add_topic"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={CONF_TOPIC: "mytopic"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@ -0,0 +1,60 @@
"""Tests for the ntfy integration."""
from unittest.mock import AsyncMock
from aiontfy.exceptions import (
NtfyConnectionError,
NtfyHTTPError,
NtfyTimeoutError,
NtfyUnauthorizedAuthenticationError,
)
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_aiontfy")
async def test_entry_setup_unload(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test integration setup and unload."""
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("exception"),
[
NtfyUnauthorizedAuthenticationError(
40101, 401, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"
),
NtfyHTTPError(418001, 418, "I'm a teapot", ""),
NtfyConnectionError,
NtfyTimeoutError,
],
)
async def test_config_entry_not_ready(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_aiontfy: AsyncMock,
exception: Exception,
) -> None:
"""Test config entry not ready."""
mock_aiontfy.account.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.SETUP_RETRY

View File

@ -0,0 +1,137 @@
"""Tests for the ntfy notify platform."""
from collections.abc import AsyncGenerator
from unittest.mock import patch
from aiontfy import Message
from aiontfy.exceptions import NtfyException, NtfyHTTPError
from freezegun.api import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.notify import (
ATTR_MESSAGE,
ATTR_TITLE,
DOMAIN as NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import AsyncMock, MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
async def notify_only() -> AsyncGenerator[None]:
"""Enable only the notify platform."""
with patch(
"homeassistant.components.ntfy.PLATFORMS",
[Platform.NOTIFY],
):
yield
@pytest.mark.usefixtures("mock_aiontfy")
async def test_notify_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test setup of the ntfy notify 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)
@freeze_time("2025-01-09T12:00:00+00:00")
async def test_send_message(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_aiontfy: AsyncMock,
) -> None:
"""Test publishing ntfy message."""
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
state = hass.states.get("notify.mytopic")
assert state
assert state.state == STATE_UNKNOWN
await hass.services.async_call(
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
{
ATTR_ENTITY_ID: "notify.mytopic",
ATTR_MESSAGE: "triggered",
ATTR_TITLE: "test",
},
blocking=True,
)
state = hass.states.get("notify.mytopic")
assert state
assert state.state == "2025-01-09T12:00:00+00:00"
mock_aiontfy.publish.assert_called_once_with(
Message(topic="mytopic", message="triggered", title="test")
)
@pytest.mark.parametrize(
("exception", "error_msg"),
[
(
NtfyHTTPError(41801, 418, "I'm a teapot", ""),
"Failed to publish notification: I'm a teapot",
),
(
NtfyException,
"Failed to publish notification due to a connection error",
),
],
)
async def test_send_message_exception(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_aiontfy: AsyncMock,
exception: Exception,
error_msg: str,
) -> None:
"""Test publish message exceptions."""
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
mock_aiontfy.publish.side_effect = exception
with pytest.raises(HomeAssistantError, match=error_msg):
await hass.services.async_call(
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
{
ATTR_ENTITY_ID: "notify.mytopic",
ATTR_MESSAGE: "triggered",
ATTR_TITLE: "test",
},
blocking=True,
)
mock_aiontfy.publish.assert_called_once_with(
Message(topic="mytopic", message="triggered", title="test")
)