From ca22b931caeb01ab57cb3501b7274e50c6c54bea Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 14 Feb 2021 14:01:30 -0500 Subject: [PATCH] Home Assistant Integration (#354) --- README.md | 1 + apprise/plugins/NotifyHomeAssistant.py | 310 +++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 14 +- setup.py | 14 +- test/test_rest_plugins.py | 78 +++++++ 5 files changed, 403 insertions(+), 14 deletions(-) create mode 100644 apprise/plugins/NotifyHomeAssistant.py 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 ##################################