mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
Add ntfy (ntfy.sh) integration (#135152)
Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
@ -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
2
CODEOWNERS
generated
@ -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
|
||||
|
78
homeassistant/components/ntfy/__init__.py
Normal file
78
homeassistant/components/ntfy/__init__.py
Normal 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)
|
216
homeassistant/components/ntfy/config_flow.py
Normal file
216
homeassistant/components/ntfy/config_flow.py
Normal 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,
|
||||
)
|
9
homeassistant/components/ntfy/const.py
Normal file
9
homeassistant/components/ntfy/const.py
Normal 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"
|
9
homeassistant/components/ntfy/icons.json
Normal file
9
homeassistant/components/ntfy/icons.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"notify": {
|
||||
"publish": {
|
||||
"default": "mdi:console-line"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
homeassistant/components/ntfy/manifest.json
Normal file
11
homeassistant/components/ntfy/manifest.json
Normal 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"]
|
||||
}
|
86
homeassistant/components/ntfy/notify.py
Normal file
86
homeassistant/components/ntfy/notify.py
Normal 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
|
84
homeassistant/components/ntfy/quality_scale.yaml
Normal file
84
homeassistant/components/ntfy/quality_scale.yaml
Normal 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
|
101
homeassistant/components/ntfy/strings.json
Normal file
101
homeassistant/components/ntfy/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@ -428,6 +428,7 @@ FLOWS = {
|
||||
"nobo_hub",
|
||||
"nordpool",
|
||||
"notion",
|
||||
"ntfy",
|
||||
"nuheat",
|
||||
"nuki",
|
||||
"nut",
|
||||
|
@ -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
10
mypy.ini
generated
@ -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
3
requirements_all.txt
generated
@ -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
|
||||
|
||||
|
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@ -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
|
||||
|
||||
|
1
tests/components/ntfy/__init__.py
Normal file
1
tests/components/ntfy/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for ntfy integration."""
|
75
tests/components/ntfy/conftest.py
Normal file
75
tests/components/ntfy/conftest.py
Normal 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",
|
||||
)
|
||||
],
|
||||
)
|
49
tests/components/ntfy/snapshots/test_notify.ambr
Normal file
49
tests/components/ntfy/snapshots/test_notify.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
355
tests/components/ntfy/test_config_flow.py
Normal file
355
tests/components/ntfy/test_config_flow.py
Normal 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"
|
60
tests/components/ntfy/test_init.py
Normal file
60
tests/components/ntfy/test_init.py
Normal 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
|
137
tests/components/ntfy/test_notify.py
Normal file
137
tests/components/ntfy/test_notify.py
Normal 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")
|
||||
)
|
Reference in New Issue
Block a user