diff --git a/README.md b/README.md
index ce597513..a9afd271 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ The table below identifies the services this tool supports and some example serv
| [Google Chat](https://github.com/caronc/apprise/wiki/Notify_googlechat) | gchat:// | (TCP) 443 | gchat://workspace/key/token
| [Gotify](https://github.com/caronc/apprise/wiki/Notify_gotify) | gotify:// or gotifys:// | (TCP) 80 or 443 | gotify://hostname/token
gotifys://hostname/token?priority=high
| [Growl](https://github.com/caronc/apprise/wiki/Notify_growl) | growl:// | (UDP) 23053 | growl://hostname
growl://hostname:portno
growl://password@hostname
growl://password@hostname:port**Note**: you can also use the get parameter _version_ which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1
+| [Home Assistant](https://github.com/caronc/apprise/wiki/Notify_homeassistant) | hassio:// or hassios:// | (TCP) 8123 or 443 | hassio://hostname/accesstoken
hassio://user@hostname/accesstoken
hassio://user:password@hostname:port/accesstoken
hassio://hostname/optional/path/accesstoken
| [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event
ifttt://webhooksID/Event1/Event2/EventN
ifttt://webhooksID/Event1/?+Key=Value
ifttt://webhooksID/Event1/?-Key=value1
| [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/
| [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port
diff --git a/apprise/plugins/NotifyHomeAssistant.py b/apprise/plugins/NotifyHomeAssistant.py
new file mode 100644
index 00000000..c896a4db
--- /dev/null
+++ b/apprise/plugins/NotifyHomeAssistant.py
@@ -0,0 +1,310 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2021 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+# You must generate a "Long-Lived Access Token". This can be done from your
+# Home Assistant Profile page.
+
+import requests
+from json import dumps
+
+from uuid import uuid4
+
+from .NotifyBase import NotifyBase
+from ..URLBase import PrivacyMode
+from ..common import NotifyType
+from ..utils import validate_regex
+from ..AppriseLocale import gettext_lazy as _
+
+
+class NotifyHomeAssistant(NotifyBase):
+ """
+ A wrapper for Home Assistant Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'HomeAssistant'
+
+ # The services URL
+ service_url = 'https://www.home-assistant.io/'
+
+ # Insecure Protocol Access
+ protocol = 'hassio'
+
+ # Secure Protocol
+ secure_protocol = 'hassios'
+
+ # Default to Home Assistant Default Insecure port of 8123 instead of 80
+ default_insecure_port = 8123
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_homeassistant'
+
+ # Define object templates
+ templates = (
+ '{schema}://{host}/{accesstoken}',
+ '{schema}://{host}:{port}/{accesstoken}',
+ '{schema}://{user}@{host}/{accesstoken}',
+ '{schema}://{user}@{host}:{port}/{accesstoken}',
+ '{schema}://{user}:{password}@{host}/{accesstoken}',
+ '{schema}://{user}:{password}@{host}:{port}/{accesstoken}',
+ )
+
+ # Define our template tokens
+ template_tokens = dict(NotifyBase.template_tokens, **{
+ 'host': {
+ 'name': _('Hostname'),
+ 'type': 'string',
+ 'required': True,
+ },
+ 'port': {
+ 'name': _('Port'),
+ 'type': 'int',
+ 'min': 1,
+ 'max': 65535,
+ },
+ 'user': {
+ 'name': _('Username'),
+ 'type': 'string',
+ },
+ 'password': {
+ 'name': _('Password'),
+ 'type': 'string',
+ 'private': True,
+ },
+ 'accesstoken': {
+ 'name': _('Long-Lived Access Token'),
+ 'type': 'string',
+ 'private': True,
+ 'required': True,
+ },
+ })
+
+ # Define our template arguments
+ template_args = dict(NotifyBase.template_args, **{
+ 'nid': {
+ # Optional Unique Notification ID
+ 'name': _('Notification ID'),
+ 'type': 'string',
+ 'regex': (r'^[a-f0-9_-]+$', 'i'),
+ },
+ })
+
+ def __init__(self, accesstoken, nid=None, **kwargs):
+ """
+ Initialize Home Assistant Object
+ """
+ super(NotifyHomeAssistant, self).__init__(**kwargs)
+
+ self.fullpath = kwargs.get('fullpath', '')
+
+ if not (self.secure or self.port):
+ # Use default insecure port
+ self.port = self.default_insecure_port
+
+ # Long-Lived Access token (generated from User Profile)
+ self.accesstoken = validate_regex(accesstoken)
+ if not self.accesstoken:
+ msg = 'An invalid Home Assistant Long-Lived Access Token ' \
+ '({}) was specified.'.format(accesstoken)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ # An Optional Notification Identifier
+ self.nid = None
+ if nid:
+ self.nid = validate_regex(
+ nid, *self.template_args['nid']['regex'])
+ if not self.nid:
+ msg = 'An invalid Home Assistant Notification Identifier ' \
+ '({}) was specified.'.format(nid)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ return
+
+ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
+ """
+ Sends Message
+ """
+
+ # Prepare our persistent_notification.create payload
+ payload = {
+ 'title': title,
+ 'message': body,
+ # Use a unique ID so we don't over-write the last message
+ # we posted. Otherwise use the notification id specified
+ 'notification_id': self.nid if self.nid else str(uuid4()),
+ }
+
+ # Prepare our headers
+ headers = {
+ 'User-Agent': self.app_id,
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer {}'.format(self.accesstoken),
+ }
+
+ auth = None
+ if self.user:
+ auth = (self.user, self.password)
+
+ # Set our schema
+ schema = 'https' if self.secure else 'http'
+
+ url = '{}://{}'.format(schema, self.host)
+ if isinstance(self.port, int):
+ url += ':%d' % self.port
+
+ url += '/' + self.fullpath.strip('/')
+ url += '/api/services/persistent_notification/create'
+
+ self.logger.debug('Home Assistant POST URL: %s (cert_verify=%r)' % (
+ url, self.verify_certificate,
+ ))
+ self.logger.debug('Home Assistant Payload: %s' % str(payload))
+
+ # Always call throttle before any remote server i/o is made
+ self.throttle()
+
+ try:
+ r = requests.post(
+ url,
+ data=dumps(payload),
+ headers=headers,
+ auth=auth,
+ verify=self.verify_certificate,
+ timeout=self.request_timeout,
+ )
+ if r.status_code != requests.codes.ok:
+ # We had a problem
+ status_str = \
+ NotifyHomeAssistant.http_response_code_lookup(
+ r.status_code)
+
+ self.logger.warning(
+ 'Failed to send Home Assistant notification: '
+ '{}{}error={}.'.format(
+ status_str,
+ ', ' if status_str else '',
+ r.status_code))
+
+ self.logger.debug('Response Details:\r\n{}'.format(r.content))
+
+ # Return; we're done
+ return False
+
+ else:
+ self.logger.info('Sent Home Assistant notification.')
+
+ except requests.RequestException as e:
+ self.logger.warning(
+ 'A Connection error occurred sending Home Assistant '
+ 'notification to %s.' % self.host)
+ self.logger.debug('Socket Exception: %s' % str(e))
+
+ # Return; we're done
+ return False
+
+ return True
+
+ def url(self, privacy=False, *args, **kwargs):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Define any URL parameters
+ params = {}
+ if self.nid:
+ params['nid'] = self.nid
+
+ # Extend our parameters
+ params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
+
+ # Determine Authentication
+ auth = ''
+ if self.user and self.password:
+ auth = '{user}:{password}@'.format(
+ user=NotifyHomeAssistant.quote(self.user, safe=''),
+ password=self.pprint(
+ self.password, privacy, mode=PrivacyMode.Secret, safe=''),
+ )
+ elif self.user:
+ auth = '{user}@'.format(
+ user=NotifyHomeAssistant.quote(self.user, safe=''),
+ )
+
+ default_port = 443 if self.secure else self.default_insecure_port
+
+ url = '{schema}://{auth}{hostname}{port}{fullpath}' \
+ '{accesstoken}/?{params}'
+
+ return url.format(
+ schema=self.secure_protocol if self.secure else self.protocol,
+ auth=auth,
+ # never encode hostname since we're expecting it to be a valid one
+ hostname=self.host,
+ port='' if not self.port or self.port == default_port
+ else ':{}'.format(self.port),
+ fullpath='/' if not self.fullpath else '/{}/'.format(
+ NotifyHomeAssistant.quote(self.fullpath.strip('/'), safe='/')),
+ accesstoken=self.pprint(self.accesstoken, privacy, safe=''),
+ params=NotifyHomeAssistant.urlencode(params),
+ )
+
+ @staticmethod
+ def parse_url(url):
+ """
+ Parses the URL and returns enough arguments that can allow
+ us to re-instantiate this object.
+
+ """
+
+ results = NotifyBase.parse_url(url, verify_host=False)
+ if not results:
+ # We're done early as we couldn't load the results
+ return results
+
+ # Get our Long-Lived Access Token
+ if 'accesstoken' in results['qsd'] and \
+ len(results['qsd']['accesstoken']):
+ results['accesstoken'] = \
+ NotifyHomeAssistant.unquote(results['qsd']['accesstoken'])
+
+ else:
+ # Acquire our full path
+ fullpath = NotifyHomeAssistant.split_path(results['fullpath'])
+
+ # Otherwise pop the last element from our path to be it
+ results['accesstoken'] = fullpath.pop() if fullpath else None
+
+ # Re-assemble our full path
+ results['fullpath'] = '/'.join(fullpath)
+
+ # Allow the specification of a unique notification_id so that
+ # it will always replace the last one sent.
+ if 'nid' in results['qsd'] and len(results['qsd']['nid']):
+ results['nid'] = \
+ NotifyHomeAssistant.unquote(results['qsd']['nid'])
+
+ return results
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index a6e22e4c..64399ac7 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -48,13 +48,13 @@ notification services that are out there. Apprise opens the door and makes
it easy to access:
Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, FCM, Flock, Gitter, Google
-Chat, Gotify, Growl, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, MacOSX,
-Mailgun, Mattermost, Matrix, Microsoft Windows, Microsoft Teams, MessageBird,
-MSG91, MyAndroid, Nexmo, Nextcloud, Notica, Notifico, Office365, OneSignal,
-Opsgenie, ParsePlatform, PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet,
-Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush, Sinch, Slack, Spontit,
-SparkPost, Super Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio,
-Twitter, Twist, XBMC, XMPP, Webex Teams}
+Chat, Gotify, Growl, Home Assistant, IFTTT, Join, Kavenegar, KODI, Kumulos,
+LaMetric, MacOSX, Mailgun, Mattermost, Matrix, Microsoft Windows, Microsoft
+Teams, MessageBird, MSG91, MyAndroid, Nexmo, Nextcloud, Notica, Notifico,
+Office365, OneSignal, Opsgenie, ParsePlatform, PopcornNotify, Prowl, Pushalot,
+PushBullet, Pushjet, Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush,
+Sinch, Slack, Spontit, SparkPost, Super Toasty, Stride, Syslog, Techulus Push,
+Telegram, Twilio, Twitter, Twist, XBMC, XMPP, Webex Teams}
Name: python-%{pypi_name}
Version: 0.9.0
diff --git a/setup.py b/setup.py
index 37de6ad2..170737d8 100755
--- a/setup.py
+++ b/setup.py
@@ -71,13 +71,13 @@ setup(
url='https://github.com/caronc/apprise',
keywords='Push Notifications Alerts Email AWS SNS Boxcar ClickSend '
'Discord Dbus Emby Faast FCM Flock Gitter Gnome Google Chat Gotify '
- 'Growl IFTTT Join Kavenegar KODI Kumulos LaMetric MacOS Mailgun '
- 'Matrix Mattermost MessageBird MSG91 Nexmo Nextcloud Notica Notifico '
- 'Office365 OneSignal Opsgenie ParsePlatform PopcornNotify Prowl '
- 'PushBullet Pushjet Pushed Pushover PushSafer Rocket.Chat Ryver '
- 'SendGrid SimplePush Sinch Slack SparkPost Spontit Stride Syslog '
- 'Techulus Telegram Twilio Twist Twitter XBMC MSTeams Microsoft '
- 'Windows Webex CLI API',
+ 'Growl Home Assistant IFTTT Join Kavenegar KODI Kumulos LaMetric '
+ 'MacOS Mailgun Matrix Mattermost MessageBird MSG91 Nexmo Nextcloud '
+ 'Notica Notifico Office365 OneSignal Opsgenie ParsePlatform '
+ 'PopcornNotify Prowl PushBullet Pushjet Pushed Pushover PushSafer '
+ 'Rocket.Chat Ryver SendGrid SimplePush Sinch Slack SparkPost Spontit '
+ 'Stride Syslog Techulus Telegram Twilio Twist Twitter XBMC MSTeams '
+ 'Microsoft Windows Webex CLI API',
author='Chris Caron',
author_email='lead2gold@gmail.com',
packages=find_packages(),
diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py
index 931912c4..c0828ead 100644
--- a/test/test_rest_plugins.py
+++ b/test/test_rest_plugins.py
@@ -941,6 +941,84 @@ TEST_URLS = (
'test_requests_exceptions': True,
}),
+ ##################################
+ # NotifyHomeAssistant
+ ##################################
+ ('hassio://:@/', {
+ 'instance': TypeError,
+ }),
+ ('hassio://', {
+ 'instance': TypeError,
+ }),
+ ('hassios://', {
+ 'instance': TypeError,
+ }),
+ # No Long Lived Access Token specified
+ ('hassio://user@localhost', {
+ 'instance': TypeError,
+ }),
+ ('hassio://localhost/long-lived-access-token', {
+ 'instance': plugins.NotifyHomeAssistant,
+ }),
+ ('hassio://user:pass@localhost/long-lived-access-token/', {
+ 'instance': plugins.NotifyHomeAssistant,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'hassio://user:****@localhost/l...n',
+ }),
+ ('hassio://localhost:80/long-lived-access-token', {
+ 'instance': plugins.NotifyHomeAssistant,
+ }),
+ ('hassio://user@localhost:8123/llat', {
+ 'instance': plugins.NotifyHomeAssistant,
+ 'privacy_url': 'hassio://user@localhost/l...t',
+ }),
+ ('hassios://localhost/llat?nid=!%', {
+ # Invalid notification_id
+ 'instance': TypeError,
+ }),
+ ('hassios://localhost/llat?nid=abcd', {
+ # Valid notification_id
+ 'instance': plugins.NotifyHomeAssistant,
+ }),
+ ('hassios://user:pass@localhost/llat', {
+ 'instance': plugins.NotifyHomeAssistant,
+ 'privacy_url': 'hassios://user:****@localhost/l...t',
+ }),
+ ('hassios://localhost:8443/path/llat/', {
+ 'instance': plugins.NotifyHomeAssistant,
+ 'privacy_url': 'hassios://localhost:8443/path/l...t',
+ }),
+ ('hassio://localhost:8123/a/path?accesstoken=llat', {
+ 'instance': plugins.NotifyHomeAssistant,
+ # Default port; so it's stripped off
+ # accesstoken was specified as kwarg
+ 'privacy_url': 'hassio://localhost/a/path/l...t',
+ }),
+ ('hassios://user:password@localhost:80/llat/', {
+ 'instance': plugins.NotifyHomeAssistant,
+
+ 'privacy_url': 'hassios://user:****@localhost:80',
+ }),
+ ('hassio://user:pass@localhost:8123/llat', {
+ 'instance': plugins.NotifyHomeAssistant,
+ # force a failure
+ 'response': False,
+ 'requests_response_code': requests.codes.internal_server_error,
+ }),
+ ('hassio://user:pass@localhost/llat', {
+ 'instance': plugins.NotifyHomeAssistant,
+ # throw a bizzare code forcing us to fail to look it up
+ 'response': False,
+ 'requests_response_code': 999,
+ }),
+ ('hassio://user:pass@localhost/llat', {
+ 'instance': plugins.NotifyHomeAssistant,
+ # Throws a series of connection and transfer exceptions when this flag
+ # is set and tests that we gracfully handle them
+ 'test_requests_exceptions': True,
+ }),
+
##################################
# NotifyIFTTT - If This Than That
##################################