diff --git a/CODEOWNERS b/CODEOWNERS index ea50d24095c1ba29a05d04acec7b7224f93d7945..8e52210cec7480716c0ab7499b1b1c097c47a34a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -26,6 +26,7 @@ homeassistant/components/ambient_station/* @bachya homeassistant/components/androidtv/* @JeffLIrion homeassistant/components/apache_kafka/* @bachya homeassistant/components/api/* @home-assistant/core +homeassistant/components/apprise/* @caronc homeassistant/components/aprs/* @PhilRW homeassistant/components/arcam_fmj/* @elupus homeassistant/components/arduino/* @fabaff diff --git a/homeassistant/components/apprise/__init__.py b/homeassistant/components/apprise/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6ffdaf690d9409bacf374f3d9467b08f7b282832 --- /dev/null +++ b/homeassistant/components/apprise/__init__.py @@ -0,0 +1 @@ +"""The apprise component.""" diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..3e971a96e7eac894e6c9ea3698dbc48d4deb539f --- /dev/null +++ b/homeassistant/components/apprise/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "apprise", + "name": "Apprise", + "documentation": "https://www.home-assistant.io/components/apprise", + "requirements": [ + "apprise==0.8.1" + ], + "dependencies": [], + "codeowners": [ + "@caronc" + ] +} diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py new file mode 100644 index 0000000000000000000000000000000000000000..662cc9c1ab6ef04df0d990ebfdee66ae697c622a --- /dev/null +++ b/homeassistant/components/apprise/notify.py @@ -0,0 +1,73 @@ +"""Apprise platform for notify component.""" +import logging + +import voluptuous as vol + +import apprise + +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.notify import ( + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) + +_LOGGER = logging.getLogger(__name__) + +CONF_FILE = "config" +CONF_URL = "url" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_URL): vol.All(cv.ensure_list, [str]), + vol.Optional(CONF_FILE): cv.string, + } +) + + +def get_service(hass, config, discovery_info=None): + """Get the Apprise notification service.""" + + # Create our object + a_obj = apprise.Apprise() + + if config.get(CONF_FILE): + # Sourced from a Configuration File + a_config = apprise.AppriseConfig() + if not a_config.add(config[CONF_FILE]): + _LOGGER.error("Invalid Apprise config url provided") + return None + + if not a_obj.add(a_config): + _LOGGER.error("Invalid Apprise config url provided") + return None + + if config.get(CONF_URL): + # Ordered list of URLs + if not a_obj.add(config[CONF_URL]): + _LOGGER.error("Invalid Apprise URL(s) supplied") + return None + + return AppriseNotificationService(a_obj) + + +class AppriseNotificationService(BaseNotificationService): + """Implement the notification service for Apprise.""" + + def __init__(self, a_obj): + """Initialize the service.""" + self.apprise = a_obj + + def send_message(self, message="", **kwargs): + """Send a message to a specified target. + + If no target/tags are specified, then services are notified as is + However, if any tags are specified, then they will be applied + to the notification causing filtering (if set up that way). + """ + targets = kwargs.get(ATTR_TARGET) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + self.apprise.notify(body=message, title=title, tag=targets) diff --git a/requirements_all.txt b/requirements_all.txt index dbc94de9a1cff36ea74ab9304d35434394417e26..ef07a3f44b72467d99812c117ea0d95be9f76f87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -217,6 +217,9 @@ apcaccess==0.0.13 # homeassistant.components.apns apns2==0.3.0 +# homeassistant.components.apprise +apprise==0.8.1 + # homeassistant.components.aprs aprslib==0.6.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18970fcbac03732fdd289ca04b6ee503d133f224..967943894fb6a208d28533db8d8ede96338d61e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -103,6 +103,9 @@ androidtv==0.0.30 # homeassistant.components.apns apns2==0.3.0 +# homeassistant.components.apprise +apprise==0.8.1 + # homeassistant.components.aprs aprslib==0.6.46 diff --git a/tests/components/apprise/__init__.py b/tests/components/apprise/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ffebc35b4e1469c27bd8ea5372f3cf6569fcf70b --- /dev/null +++ b/tests/components/apprise/__init__.py @@ -0,0 +1 @@ +"""Tests for the apprise component.""" diff --git a/tests/components/apprise/test_notify.py b/tests/components/apprise/test_notify.py new file mode 100644 index 0000000000000000000000000000000000000000..237f99de676faae9b3c6363f86a8e9dc8beedcb5 --- /dev/null +++ b/tests/components/apprise/test_notify.py @@ -0,0 +1,148 @@ +"""The tests for the apprise notification platform.""" +from unittest.mock import patch +from unittest.mock import MagicMock + +from homeassistant.setup import async_setup_component + +BASE_COMPONENT = "notify" + + +async def test_apprise_config_load_fail01(hass): + """Test apprise configuration failures 1.""" + + config = { + BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"} + } + + with patch("apprise.AppriseConfig.add", return_value=False): + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test that our service failed to load + assert not hass.services.has_service(BASE_COMPONENT, "test") + + +async def test_apprise_config_load_fail02(hass): + """Test apprise configuration failures 2.""" + + config = { + BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"} + } + + with patch("apprise.Apprise.add", return_value=False): + with patch("apprise.AppriseConfig.add", return_value=True): + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test that our service failed to load + assert not hass.services.has_service(BASE_COMPONENT, "test") + + +async def test_apprise_config_load_okay(hass, tmp_path): + """Test apprise configuration failures.""" + + # Test cases where our URL is invalid + d = tmp_path / "apprise-config" + d.mkdir() + f = d / "apprise" + f.write_text("mailto://user:pass@example.com/") + + config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}} + + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Valid configuration was loaded; our service is good + assert hass.services.has_service(BASE_COMPONENT, "test") + + +async def test_apprise_url_load_fail(hass): + """Test apprise url failure.""" + + config = { + BASE_COMPONENT: { + "name": "test", + "platform": "apprise", + "url": "mailto://user:pass@example.com", + } + } + with patch("apprise.Apprise.add", return_value=False): + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test that our service failed to load + assert not hass.services.has_service(BASE_COMPONENT, "test") + + +async def test_apprise_notification(hass): + """Test apprise notification.""" + + config = { + BASE_COMPONENT: { + "name": "test", + "platform": "apprise", + "url": "mailto://user:pass@example.com", + } + } + + # Our Message + data = {"title": "Test Title", "message": "Test Message"} + + with patch("apprise.Apprise") as mock_apprise: + obj = MagicMock() + obj.add.return_value = True + obj.notify.return_value = True + mock_apprise.return_value = obj + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test the existance of our service + assert hass.services.has_service(BASE_COMPONENT, "test") + + # Test the call to our underlining notify() call + await hass.services.async_call(BASE_COMPONENT, "test", data) + await hass.async_block_till_done() + + # Validate calls were made under the hood correctly + obj.add.assert_called_once_with([config[BASE_COMPONENT]["url"]]) + obj.notify.assert_called_once_with( + **{"body": data["message"], "title": data["title"], "tag": None} + ) + + +async def test_apprise_notification_with_target(hass, tmp_path): + """Test apprise notification with a target.""" + + # Test cases where our URL is invalid + d = tmp_path / "apprise-config" + d.mkdir() + f = d / "apprise" + + # Write 2 config entries each assigned to different tags + f.write_text("devops=mailto://user:pass@example.com/\r\n") + f.write_text("system,alert=syslog://\r\n") + + config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}} + + # Our Message, only notify the services tagged with "devops" + data = {"title": "Test Title", "message": "Test Message", "target": ["devops"]} + + with patch("apprise.Apprise") as mock_apprise: + apprise_obj = MagicMock() + apprise_obj.add.return_value = True + apprise_obj.notify.return_value = True + mock_apprise.return_value = apprise_obj + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test the existance of our service + assert hass.services.has_service(BASE_COMPONENT, "test") + + # Test the call to our underlining notify() call + await hass.services.async_call(BASE_COMPONENT, "test", data) + await hass.async_block_till_done() + + # Validate calls were made under the hood correctly + apprise_obj.notify.assert_called_once_with( + **{"body": data["message"], "title": data["title"], "tag": data["target"]} + )