Home Assistant Service Notification Support Added

pull/1294/head
Chris Caron 2025-02-21 17:39:17 -05:00
parent 0553bf7592
commit 5d835e0941
5 changed files with 669 additions and 66 deletions

View File

@ -72,7 +72,7 @@ The table below identifies the services this tool supports and some example serv
| [Gotify](https://github.com/caronc/apprise/wiki/Notify_gotify) | gotify:// or gotifys:// | (TCP) 80 or 443 | gotify://hostname/token<br />gotifys://hostname/token?priority=high | [Gotify](https://github.com/caronc/apprise/wiki/Notify_gotify) | gotify:// or gotifys:// | (TCP) 80 or 443 | gotify://hostname/token<br />gotifys://hostname/token?priority=high
| [Growl](https://github.com/caronc/apprise/wiki/Notify_growl) | growl:// | (UDP) 23053 | growl://hostname<br />growl://hostname:portno<br />growl://password@hostname<br />growl://password@hostname:port</br>**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 | [Growl](https://github.com/caronc/apprise/wiki/Notify_growl) | growl:// | (UDP) 23053 | growl://hostname<br />growl://hostname:portno<br />growl://password@hostname<br />growl://password@hostname:port</br>**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
| [Guilded](https://github.com/caronc/apprise/wiki/Notify_guilded) | guilded:// | (TCP) 443 | guilded://webhook_id/webhook_token<br />guilded://avatar@webhook_id/webhook_token | [Guilded](https://github.com/caronc/apprise/wiki/Notify_guilded) | guilded:// | (TCP) 443 | guilded://webhook_id/webhook_token<br />guilded://avatar@webhook_id/webhook_token
| [Home Assistant](https://github.com/caronc/apprise/wiki/Notify_homeassistant) | hassio:// or hassios:// | (TCP) 8123 or 443 | hassio://hostname/accesstoken<br />hassio://user@hostname/accesstoken<br />hassio://user:password@hostname:port/accesstoken<br />hassio://hostname/optional/path/accesstoken | [Home Assistant](https://github.com/caronc/apprise/wiki/Notify_homeassistant) | hassio:// or hassios:// | (TCP) 8123 or 443 | hassio://hostname/long-lived-token<br />hassio://user@hostname/long-lived-token<br />hassio://user:password@hostname:port/long-lived-token<br />hassio://hostname/optional/path/long-lived-token<br />hassio://user@hostname/long-lived-token/service<br />hassio://user@hostname/long-lived-token/Service1/Service2/ServiceN
| [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event<br />ifttt://webhooksID/Event1/Event2/EventN<br/>ifttt://webhooksID/Event1/?+Key=Value<br/>ifttt://webhooksID/Event1/?-Key=value1 | [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event<br />ifttt://webhooksID/Event1/Event2/EventN<br/>ifttt://webhooksID/Event1/?+Key=Value<br/>ifttt://webhooksID/Event1/?-Key=value1
| [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device<br />join://apikey/device1/device2/deviceN/<br />join://apikey/group<br />join://apikey/groupA/groupB/groupN<br />join://apikey/DeviceA/groupA/groupN/DeviceN/ | [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device<br />join://apikey/device1/device2/deviceN/<br />join://apikey/group<br />join://apikey/groupA/groupB/groupN<br />join://apikey/DeviceA/groupA/groupN/DeviceN/
| [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname<br />kodi://user@hostname<br />kodi://user:password@hostname:port | [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname<br />kodi://user@hostname<br />kodi://user:password@hostname:port

View File

@ -28,11 +28,16 @@
# You must generate a "Long-Lived Access Token". This can be done from your # You must generate a "Long-Lived Access Token". This can be done from your
# Home Assistant Profile page. # Home Assistant Profile page.
import re
import math
import requests import requests
from itertools import chain
from json import dumps from json import dumps
from uuid import uuid4 from uuid import uuid4
from ..utils.parse import (
parse_bool, parse_domain_service_targets,
is_domain_service_target)
from .base import NotifyBase from .base import NotifyBase
from ..url import PrivacyMode from ..url import PrivacyMode
@ -40,6 +45,27 @@ from ..common import NotifyType
from ..utils.parse import validate_regex from ..utils.parse import validate_regex
from ..locale import gettext_lazy as _ from ..locale import gettext_lazy as _
# This regex matches exactly 8 hex digits,
# a dot, then exactly 64 hex digits. it can also be a JWT
# token in which case it will be 180 characters+
RE_IS_LONG_LIVED_TOKEN = re.compile(
r'^([0-9a-f]{8}\.[0-9a-f]{64}|[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+)$',
re.I)
# Define our supported device notification formats:
# - service
# - default domain is always 'notify' if one isn't detected
# - service:target
# - service:target1,target2,target3
# - domain.service
# - domain.service:target
# - domain.service:target1,target2,target3
# - - targets can be comma/space separated if more hten one
# - service:target1,target2,target3
# Define a persistent entry (used for handling message delivery
PERSISTENT_ENTRY = (None, None, [])
class NotifyHomeAssistant(NotifyBase): class NotifyHomeAssistant(NotifyBase):
""" """
@ -61,17 +87,29 @@ class NotifyHomeAssistant(NotifyBase):
# Default to Home Assistant Default Insecure port of 8123 instead of 80 # Default to Home Assistant Default Insecure port of 8123 instead of 80
default_insecure_port = 8123 default_insecure_port = 8123
# The maximum amount of services that can be notified in a single batch
default_batch_size = 10
# The default ha notification domain if one isn't detected
default_domain = 'notify'
# A URL that takes you to the setup/help of the specific protocol # A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_homeassistant' setup_url = 'https://github.com/caronc/apprise/wiki/Notify_homeassistant'
# Define object templates # Define object templates
templates = ( templates = (
'{schema}://{host}/{accesstoken}', '{schema}://{host}/{token}',
'{schema}://{host}:{port}/{accesstoken}', '{schema}://{host}:{port}/{token}',
'{schema}://{user}@{host}/{accesstoken}', '{schema}://{user}@{host}/{token}',
'{schema}://{user}@{host}:{port}/{accesstoken}', '{schema}://{user}@{host}:{port}/{token}',
'{schema}://{user}:{password}@{host}/{accesstoken}', '{schema}://{user}:{password}@{host}/{token}',
'{schema}://{user}:{password}@{host}:{port}/{accesstoken}', '{schema}://{user}:{password}@{host}:{port}/{token}',
'{schema}://{host}/{token}/{targets}',
'{schema}://{host}:{port}/{token}/{targets}',
'{schema}://{user}@{host}/{token}/{targets}',
'{schema}://{user}@{host}:{port}/{token}/{targets}',
'{schema}://{user}:{password}@{host}/{token}/{targets}',
'{schema}://{user}:{password}@{host}:{port}/{token}/{targets}',
) )
# Define our template tokens # Define our template tokens
@ -96,12 +134,21 @@ class NotifyHomeAssistant(NotifyBase):
'type': 'string', 'type': 'string',
'private': True, 'private': True,
}, },
'accesstoken': { 'token': {
'name': _('Long-Lived Access Token'), 'name': _('Long-Lived Access Token'),
'type': 'string', 'type': 'string',
'private': True, 'private': True,
'required': True, 'required': True,
}, },
'target_device': {
'name': _('Target Device'),
'type': 'string',
'map_to': 'targets',
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
},
}) })
# Define our template arguments # Define our template arguments
@ -112,25 +159,40 @@ class NotifyHomeAssistant(NotifyBase):
'type': 'string', 'type': 'string',
'regex': (r'^[a-z0-9_-]+$', 'i'), 'regex': (r'^[a-z0-9_-]+$', 'i'),
}, },
'prefix': {
# Path Prefix to use (for those not hosting their hasio instance
# in /)
'name': _('Path Prefix'),
'type': 'string',
},
'batch': {
'name': _('Batch Mode'),
'type': 'bool',
'default': False,
},
'to': {
'alias_of': 'targets',
},
}) })
def __init__(self, accesstoken, nid=None, **kwargs): def __init__(self, token, nid=None, targets=None, prefix=None,
batch=None, **kwargs):
""" """
Initialize Home Assistant Object Initialize Home Assistant Object
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self.fullpath = kwargs.get('fullpath', '') self.prefix = prefix or kwargs.get('fullpath', '')
if not (self.secure or self.port): if not (self.secure or self.port):
# Use default insecure port # Use default insecure port
self.port = self.default_insecure_port self.port = self.default_insecure_port
# Long-Lived Access token (generated from User Profile) # Long-Lived Access token (generated from User Profile)
self.accesstoken = validate_regex(accesstoken) self.token = validate_regex(token)
if not self.accesstoken: if not self.token:
msg = 'An invalid Home Assistant Long-Lived Access Token ' \ msg = 'An invalid Home Assistant Long-Lived Access Token ' \
'({}) was specified.'.format(accesstoken) '({}) was specified.'.format(token)
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(msg) raise TypeError(msg)
@ -145,6 +207,36 @@ class NotifyHomeAssistant(NotifyBase):
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(msg) raise TypeError(msg)
# Prepare Batch Mode Flag
self.batch = self.template_args['batch']['default'] \
if batch is None else batch
# Store our targets
self.targets = []
# Track our invalid targets
self._invalid_targets = list()
if targets:
for target in parse_domain_service_targets(targets):
result = is_domain_service_target(
target, domain=self.default_domain)
if result:
self.targets.append((
result['domain'],
result['service'],
result['targets'],
))
continue
self.logger.warning(
'Dropped invalid [domain.]service[:target] entry '
'({}) specified.'.format(target),
)
self._invalid_targets.append(target)
else:
self.targets = [PERSISTENT_ENTRY]
return return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
@ -152,20 +244,22 @@ class NotifyHomeAssistant(NotifyBase):
Sends Message Sends Message
""" """
if not self.targets:
self.logger.warning(
'There are no valid Home Assistant targets to notify.')
return False
# Prepare our persistent_notification.create payload # Prepare our persistent_notification.create payload
payload = { payload = {
'title': title, 'title': title,
'message': body, '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 # Prepare our headers
headers = { headers = {
'User-Agent': self.app_id, 'User-Agent': self.app_id,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer {}'.format(self.accesstoken), 'Authorization': 'Bearer {}'.format(self.token),
} }
auth = None auth = None
@ -179,17 +273,62 @@ class NotifyHomeAssistant(NotifyBase):
if isinstance(self.port, int): if isinstance(self.port, int):
url += ':%d' % self.port url += ':%d' % self.port
url += self.fullpath.rstrip('/') + \ # Determine if we're doing it the old way (using persistent notices)
# or the new (supporting device targets)
has_targets = True if not self.targets or self.targets[0] \
is not PERSISTENT_ENTRY else False
# our base url
base_url = url + self.prefix.rstrip('/') + \
'/api/services/persistent_notification/create' '/api/services/persistent_notification/create'
self.logger.debug('Home Assistant POST URL: %s (cert_verify=%r)' % ( # Send in batches if identified to do so
url, self.verify_certificate, batch_size = 1 if not self.batch else self.default_batch_size
))
for target in self.targets:
# Use a unique ID so we don't over-write the last message we
# posted. Otherwise use the notification id specified
if has_targets:
# Base target details
domain = target[0]
service = target[1]
# Prepare our URL
base_url = url + self.prefix.rstrip('/') + \
f'/api/services/{domain}/{service}'
# Possibly prepare batches
if target[2]:
_payload = payload.copy()
for index in range(0, len(target[2]), batch_size):
_payload['targets'] = \
target[2][index:index + batch_size]
if not self._ha_post(
base_url, _payload, headers, auth):
return False
# We're done
return True
if not self._ha_post(base_url, payload, headers, auth):
return False
return True
def _ha_post(self, url, payload, headers, auth=None):
"""
Wrapper to single upstream server post
"""
# Notification ID
payload['notification_id'] = self.nid if self.nid else str(uuid4())
self.logger.debug(
'Home Assistant POST URL: %s (cert_verify=%r)' % (
url, self.verify_certificate))
self.logger.debug('Home Assistant Payload: %s' % str(payload)) self.logger.debug('Home Assistant Payload: %s' % str(payload))
# Always call throttle before any remote server i/o is made # Always call throttle before any remote server i/o is made
self.throttle() self.throttle()
try: try:
r = requests.post( r = requests.post(
url, url,
@ -243,8 +382,8 @@ class NotifyHomeAssistant(NotifyBase):
self.user, self.password, self.host, self.user, self.password, self.host,
self.port if self.port else ( self.port if self.port else (
443 if self.secure else self.default_insecure_port), 443 if self.secure else self.default_insecure_port),
self.fullpath.rstrip('/'), self.prefix.rstrip('/'),
self.accesstoken, self.token,
) )
def url(self, privacy=False, *args, **kwargs): def url(self, privacy=False, *args, **kwargs):
@ -253,7 +392,14 @@ class NotifyHomeAssistant(NotifyBase):
""" """
# Define any URL parameters # Define any URL parameters
params = {} params = {
'batch': 'yes' if self.batch else 'no',
}
if self.prefix not in ('', '/'):
params['prefix'] = '/' if not self.prefix \
else '/{}/'.format(self.prefix.strip('/'))
if self.nid: if self.nid:
params['nid'] = self.nid params['nid'] = self.nid
@ -275,8 +421,13 @@ class NotifyHomeAssistant(NotifyBase):
default_port = 443 if self.secure else self.default_insecure_port default_port = 443 if self.secure else self.default_insecure_port
url = '{schema}://{auth}{hostname}{port}{fullpath}' \ url = '{schema}://{auth}{hostname}{port}/' \
'{accesstoken}/?{params}' '{token}/{targets}?{params}'
# Determine if we're doing it the old way (using persistent notices)
# or the new (supporting device targets)
has_targets = True if not self.targets or self.targets[0] \
is not PERSISTENT_ENTRY else False
return url.format( return url.format(
schema=self.secure_protocol if self.secure else self.protocol, schema=self.secure_protocol if self.secure else self.protocol,
@ -285,12 +436,39 @@ class NotifyHomeAssistant(NotifyBase):
hostname=self.host, hostname=self.host,
port='' if not self.port or self.port == default_port port='' if not self.port or self.port == default_port
else ':{}'.format(self.port), else ':{}'.format(self.port),
fullpath='/' if not self.fullpath else '/{}/'.format( token=self.pprint(self.token, privacy, safe=''),
NotifyHomeAssistant.quote(self.fullpath.strip('/'), safe='/')), targets='' if not has_targets else '/'.join(
accesstoken=self.pprint(self.accesstoken, privacy, safe=''), chain([NotifyHomeAssistant.quote('{}.{}{}'.format(
x[0], x[1], ''
if not x[2] else ':' + ','.join(x[2])), safe='')
for x in self.targets],
[NotifyHomeAssistant.quote(x, safe='')
for x in self._invalid_targets])),
params=NotifyHomeAssistant.urlencode(params), params=NotifyHomeAssistant.urlencode(params),
) )
def __len__(self):
"""
Returns the number of targets associated with this notification
"""
#
# Factor batch into calculation
#
# Determine if we're doing it the old way (using persistent notices)
# or the new (supporting device targets)
has_targets = True if not self.targets or self.targets[0] \
is not PERSISTENT_ENTRY else False
if not has_targets:
return 1
# Handle targets
batch_size = 1 if not self.batch else self.default_batch_size
return sum(
math.ceil(len(identities) / batch_size)
if identities else 1 for _, _, identities in self.targets)
@staticmethod @staticmethod
def parse_url(url): def parse_url(url):
""" """
@ -304,21 +482,57 @@ class NotifyHomeAssistant(NotifyBase):
# We're done early as we couldn't load the results # We're done early as we couldn't load the results
return results return results
# Get our Long-Lived Access Token # Set our path to use:
if 'accesstoken' in results['qsd'] and \ if 'prefix' in results['qsd'] and len(results['qsd']['prefix']):
len(results['qsd']['accesstoken']): results['prefix'] = \
results['accesstoken'] = \ NotifyHomeAssistant.unquote(results['qsd']['prefix'])
NotifyHomeAssistant.unquote(results['qsd']['accesstoken'])
# Long Lived Access token placeholder
results['token'] = None
# Get our Long-Lived Access Token (if defined)
if 'token' in results['qsd'] and \
len(results['qsd']['token']):
results['token'] = \
NotifyHomeAssistant.unquote(results['qsd']['token'])
else:
# Acquire our full path # Acquire our full path
fullpath = NotifyHomeAssistant.split_path(results['fullpath']) tokens = NotifyHomeAssistant.split_path(results['fullpath'])
results['targets'] = []
# Otherwise pop the last element from our path to be it while tokens:
results['accesstoken'] = fullpath.pop() if fullpath else None # Iterate through our tokens
token = tokens.pop()
if not results['token']:
if RE_IS_LONG_LIVED_TOKEN.match(token):
# Store our access token
results['token'] = token
# Re-assemble our full path # Re-assemble our full path
results['fullpath'] = '/' + '/'.join(fullpath) if fullpath else '' results['fullpath'] = '/' + '/'.join(tokens)
continue
# If we don't have an access token, then we can assume
# it's a device we're storing
results['targets'].append(token)
continue
elif 'prefix' not in results:
# Re-assemble our full path
results['fullpath'] = '/' + '/'.join(tokens + [token])
# We're done
break
# prefix is in the result set, so therefore we're dealing with a
# custom target/service
results['targets'].append(token)
# Get Batch Mode Flag
results['batch'] = \
parse_bool(results['qsd'].get(
'batch',
NotifyHomeAssistant.template_args['batch']['default']))
# Allow the specification of a unique notification_id so that # Allow the specification of a unique notification_id so that
# it will always replace the last one sent. # it will always replace the last one sent.

View File

@ -101,6 +101,15 @@ IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$')
PHONE_NO_DETECTION_RE = re.compile( PHONE_NO_DETECTION_RE = re.compile(
r'\s*([+(\s]*[0-9][0-9()\s-]+[0-9])(?=$|[\s,+(]+[0-9])', re.I) r'\s*([+(\s]*[0-9][0-9()\s-]+[0-9])(?=$|[\s,+(]+[0-9])', re.I)
IS_DOMAIN_SERVICE_TARGET = re.compile(
r'\s*((?P<domain>[a-z0-9_-]+)\.)?(?P<service>[a-z0-9_-]+)'
r'(:(?P<targets>[a-z0-9_,-]+))?', re.I)
DOMAIN_SERVICE_TARGET_DETECTION_RE = re.compile(
r'\s*((?:[a-z0-9_-]+\.)?[a-z0-9_-]+'
r'(?::(?:[a-z0-9_-]+(?:,+[a-z0-9_-]+)+?))?)'
r'(?=$|(?:\s|,+\s|\s,+)+(?:[a-z0-9_-]+\.)?[a-z0-9_-]+)', re.I)
# Support for prefix: (string followed by colon) infront of phone no # Support for prefix: (string followed by colon) infront of phone no
PHONE_NO_WPREFIX_DETECTION_RE = re.compile( PHONE_NO_WPREFIX_DETECTION_RE = re.compile(
r'\s*((?:[a-z]+:)?[+(\s]*[0-9][0-9()\s-]+[0-9])' r'\s*((?:[a-z]+:)?[+(\s]*[0-9][0-9()\s-]+[0-9])'
@ -254,6 +263,44 @@ def is_uuid(uuid):
return True if match else False return True if match else False
def is_domain_service_target(entry, domain='notify'):
"""Determine if the specified entry a domain.service:target type
Expects a string containing the following formats:
- service
- service:target
- service:target1,target2
- domain.service:target
- domain.service:target1,target2
Args:
entry (str): The string you want to check.
Returns:
bool: Returns False if the entry specified is domain.service:target
"""
try:
result = IS_DOMAIN_SERVICE_TARGET.match(entry)
if not result:
# not parseable content as it does not even conform closely to a
# domain.service:target
return False
except TypeError:
# not parseable content
return False
return {
# Store domain or set default if not acquired
'domain': result.group('domain') if result.group('domain') else domain,
# store service
'service': result.group('service'),
# store targets if defined
'targets': parse_list(result.group('targets'))
}
def is_phone_no(phone, min_len=10): def is_phone_no(phone, min_len=10):
"""Determine if the specified entry is a phone number """Determine if the specified entry is a phone number
@ -353,7 +400,7 @@ def is_call_sign(callsign):
callsign (str): The string you want to check. callsign (str): The string you want to check.
Returns: Returns:
bool: Returns False if the address specified is not a phone number bool: Returns False if the enry specified is not a callsign
""" """
try: try:
@ -824,6 +871,47 @@ def parse_bool(arg, default=False):
return bool(arg) return bool(arg)
def parse_domain_service_targets(
*args, store_unparseable=True, domain='notify', **kwargs):
"""
Takes a string containing the following formats separated by space
- service
- service:target
- service:target1,target2
- domain.service:target
- domain.service:target1,target2
If no domain is parsed, the default domain is returned.
Targets can be comma separated (if multiple are to be defined)
"""
result = []
for arg in args:
if isinstance(arg, str) and arg:
_result = DOMAIN_SERVICE_TARGET_DETECTION_RE.findall(arg)
if _result:
result += _result
elif not _result and store_unparseable:
# we had content passed into us that was lost because it was
# so poorly formatted that it didn't even come close to
# meeting the regular expression we defined. We intentially
# keep it as part of our result set so that parsing done
# at a higher level can at least report this to the end user
# and hopefully give them some indication as to what they
# may have done wrong.
result += \
[x for x in filter(bool, re.split(STRING_DELIMITERS, arg))]
elif isinstance(arg, (set, list, tuple)):
# Use recursion to handle the list of phone numbers
result += parse_domain_service_targets(
*arg, store_unparseable=store_unparseable, domain=domain)
return result
def parse_phone_no(*args, store_unparseable=True, prefix=False, **kwargs): def parse_phone_no(*args, store_unparseable=True, prefix=False, **kwargs):
""" """
Takes a string containing phone numbers separated by comma's and/or spaces Takes a string containing phone numbers separated by comma's and/or spaces

View File

@ -1536,6 +1536,66 @@ def test_is_email():
assert 'a-z0-9_!#$%&*/=?%`{|}~^.-' == results['user'] assert 'a-z0-9_!#$%&*/=?%`{|}~^.-' == results['user']
def test_is_domain_service_target():
"""
API: is_domain_service_target() function
"""
# Invalid information
assert utils.parse.is_domain_service_target(None) is False
assert utils.parse.is_domain_service_target(42) is False
assert utils.parse.is_domain_service_target(object) is False
assert utils.parse.is_domain_service_target('') is False
assert utils.parse.is_domain_service_target('+()') is False
assert utils.parse.is_domain_service_target('+') is False
# Valid entries
result = utils.parse.is_domain_service_target('service')
assert isinstance(result, dict)
assert 'service' == result['service']
# Default domain
assert 'notify' == result['domain']
assert isinstance(result['targets'], list)
assert len(result['targets']) == 0
result = utils.parse.is_domain_service_target('domain.service')
assert isinstance(result, dict)
assert 'service' == result['service']
assert 'domain' == result['domain']
assert isinstance(result['targets'], list)
assert len(result['targets']) == 0
result = utils.parse.is_domain_service_target('domain.service:target')
assert isinstance(result, dict)
assert 'service' == result['service']
assert 'domain' == result['domain']
assert isinstance(result['targets'], list)
assert len(result['targets']) == 1
assert result['targets'][0] == 'target'
result = utils.parse.is_domain_service_target('domain.service:t1,t2,t3')
assert isinstance(result, dict)
assert 'service' == result['service']
assert 'domain' == result['domain']
assert isinstance(result['targets'], list)
assert len(result['targets']) == 3
assert 't1' in result['targets']
assert 't2' in result['targets']
assert 't3' in result['targets']
result = utils.parse.is_domain_service_target(
'service:t1,t2,t3', domain='new_default')
assert isinstance(result, dict)
assert 'service' == result['service']
# Default domain
assert 'new_default' == result['domain']
assert isinstance(result['targets'], list)
assert len(result['targets']) == 3
assert 't1' in result['targets']
assert 't2' in result['targets']
assert 't3' in result['targets']
def test_is_call_sign_no(): def test_is_call_sign_no():
""" """
API: is_call_sign() function API: is_call_sign() function
@ -1551,8 +1611,6 @@ def test_is_call_sign_no():
assert utils.parse.is_call_sign('abc') is False assert utils.parse.is_call_sign('abc') is False
assert utils.parse.is_call_sign('+()') is False assert utils.parse.is_call_sign('+()') is False
assert utils.parse.is_call_sign('+') is False assert utils.parse.is_call_sign('+') is False
assert utils.parse.is_call_sign(None) is False
assert utils.parse.is_call_sign(42) is False
# To short or 2 long # To short or 2 long
assert utils.parse.is_call_sign('DF1AB') is False assert utils.parse.is_call_sign('DF1AB') is False
@ -1728,6 +1786,57 @@ def test_parse_call_sign():
assert 'DF1ABC' in results assert 'DF1ABC' in results
def test_parse_domain_service_targets():
"""utils: parse_domain_service_targets() testing """
results = utils.parse.parse_domain_service_targets('')
assert isinstance(results, list)
assert len(results) == 0
results = utils.parse.parse_domain_service_targets('service1 service2')
assert isinstance(results, list)
assert len(results) == 2
assert 'service1' in results
assert 'service2' in results
results = utils.parse.parse_domain_service_targets(
'service1:target1,target2')
assert isinstance(results, list)
assert len(results) == 1
assert 'service1:target1,target2' in results
results = utils.parse.parse_domain_service_targets(
'service1:target1,target2 service2 domain.service3')
assert isinstance(results, list)
assert len(results) == 3
assert 'service1:target1,target2' in results
assert 'service2' in results
assert 'domain.service3' in results
# Support a comma in the space between entries
results = utils.parse.parse_domain_service_targets(
'service1:target1,target2, service2 ,domain.service3,'
' , , service4')
assert isinstance(results, list)
assert len(results) == 4
assert 'service1:target1,target2' in results
assert 'service2' in results
assert 'domain.service3' in results
assert 'service4' in results
results = utils.parse.parse_domain_service_targets(
'service:target1,target2')
assert isinstance(results, list)
assert len(results) == 1
assert 'service:target1,target2' in results
# Handle unparseables
results = utils.parse.parse_domain_service_targets(
': %invalid ^entries%', store_unparseable=False)
assert isinstance(results, list)
assert len(results) == 0
def test_parse_phone_no(): def test_parse_phone_no():
"""utils: parse_phone_no() testing """ """utils: parse_phone_no() testing """
# A simple single array entry (As str) # A simple single array entry (As str)

View File

@ -32,6 +32,7 @@ import requests
from apprise import Apprise from apprise import Apprise
from apprise.plugins.home_assistant import NotifyHomeAssistant from apprise.plugins.home_assistant import NotifyHomeAssistant
from helpers import AppriseURLTester from helpers import AppriseURLTester
import json
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
@ -52,62 +53,84 @@ apprise_url_tests = (
('hassio://user@localhost', { ('hassio://user@localhost', {
'instance': TypeError, 'instance': TypeError,
}), }),
('hassio://localhost/long-lived-access-token', { ('hassio://localhost/long.lived.token', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
}), }),
('hassio://user:pass@localhost/long-lived-access-token/', { ('hassio://localhost/prefix/path/long.lived.token', {
'instance': NotifyHomeAssistant,
}),
('hassio://localhost/long.lived.token?prefix=/ha', {
'instance': NotifyHomeAssistant,
}),
('hassio://localhost/service/?token=long.lived.token&prefix=/ha', {
'instance': NotifyHomeAssistant,
}),
('hassio://localhost/?token=long.lived.token&prefix=/ha&to=service', {
'instance': NotifyHomeAssistant,
}),
('hassio://localhost/service/$%/?token=long.lived.token&prefix=/ha', {
# Tests an invalid service entry
'instance': NotifyHomeAssistant,
}),
('hassio://localhost/%only%/%invalid%/?token=lng.lived.token&prefix=/ha', {
# Tests an invalid service entry
'instance': NotifyHomeAssistant,
# we'll have a notify response failure in this case
'notify_response': False,
}),
('hassio://user:pass@localhost/long.lived.token/', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
# Our expected url(privacy=True) startswith() response: # Our expected url(privacy=True) startswith() response:
'privacy_url': 'hassio://user:****@localhost/l...n', 'privacy_url': 'hassio://user:****@localhost/l...n',
}), }),
('hassio://localhost:80/long-lived-access-token', { ('hassio://localhost:80/long.lived.token', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
}), }),
('hassio://user@localhost:8123/llat', { ('hassio://user@localhost:8123/long.lived.token', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
'privacy_url': 'hassio://user@localhost/l...t', 'privacy_url': 'hassio://user@localhost/l...n',
}), }),
('hassios://localhost/llat?nid=!%', { ('hassios://localhost/long.lived.token?nid=!%', {
# Invalid notification_id # Invalid notification_id
'instance': TypeError, 'instance': TypeError,
}), }),
('hassios://localhost/llat?nid=abcd', { ('hassios://localhost/long.lived.token?nid=abcd', {
# Valid notification_id # Valid notification_id
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
}), }),
('hassios://user:pass@localhost/llat', { ('hassios://user:pass@localhost/long.lived.token', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
'privacy_url': 'hassios://user:****@localhost/l...t', 'privacy_url': 'hassios://user:****@localhost/l...n',
}), }),
('hassios://localhost:8443/path/llat/', { ('hassios://localhost:8443/path/long.lived.token/', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
'privacy_url': 'hassios://localhost:8443/path/l...t', 'privacy_url': 'hassios://localhost:8443/l...n',
}), }),
('hassio://localhost:8123/a/path?accesstoken=llat', { ('hassio://localhost:8123/a/path?token=long.lived.token', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
# Default port; so it's stripped off # Default port; so it's stripped off
# accesstoken was specified as kwarg # token was specified as kwarg
'privacy_url': 'hassio://localhost/a/path/l...t', 'privacy_url': 'hassio://localhost/l...n',
}), }),
('hassios://user:password@localhost:80/llat/', { ('hassios://user:password@localhost:80/long.lived.token/', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
'privacy_url': 'hassios://user:****@localhost:80', 'privacy_url': 'hassios://user:****@localhost:80',
}), }),
('hassio://user:pass@localhost:8123/llat', { ('hassio://user:pass@localhost:8123/long.lived.token', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
# force a failure # force a failure
'response': False, 'response': False,
'requests_response_code': requests.codes.internal_server_error, 'requests_response_code': requests.codes.internal_server_error,
}), }),
('hassio://user:pass@localhost/llat', { ('hassio://user:pass@localhost/long.lived.token', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
# throw a bizzare code forcing us to fail to look it up # throw a bizzare code forcing us to fail to look it up
'response': False, 'response': False,
'requests_response_code': 999, 'requests_response_code': 999,
}), }),
('hassio://user:pass@localhost/llat', { ('hassio://user:pass@localhost/long.lived.token', {
'instance': NotifyHomeAssistant, 'instance': NotifyHomeAssistant,
# Throws a series of connection and transfer exceptions when this flag # Throws a series of connection and transfer exceptions when this flag
# is set and tests that we gracfully handle them # is set and tests that we gracfully handle them
@ -140,8 +163,8 @@ def test_plugin_homeassistant_general(mock_post):
# Prepare Mock # Prepare Mock
mock_post.return_value = response mock_post.return_value = response
# Variation Initializations # Initializations
obj = Apprise.instantiate('hassio://localhost/accesstoken') obj = Apprise.instantiate('hassio://localhost/long.lived.token')
assert isinstance(obj, NotifyHomeAssistant) is True assert isinstance(obj, NotifyHomeAssistant) is True
assert isinstance(obj.url(), str) is True assert isinstance(obj.url(), str) is True
@ -151,3 +174,172 @@ def test_plugin_homeassistant_general(mock_post):
assert mock_post.call_count == 1 assert mock_post.call_count == 1
assert mock_post.call_args_list[0][0][0] == \ assert mock_post.call_args_list[0][0][0] == \
'http://localhost:8123/api/services/persistent_notification/create' 'http://localhost:8123/api/services/persistent_notification/create'
# Reset our mock object
mock_post.reset_mock()
# Now let's notify an object
obj = Apprise.instantiate(
'hassio://localhost/long.lived.token/service')
assert isinstance(obj, NotifyHomeAssistant) is True
assert isinstance(obj.url(), str) is True
# Send Notification
assert obj.send(body="test") is True
assert mock_post.call_args_list[0][0][0] == \
'http://localhost:8123/api/services/notify/service'
posted_json = json.loads(mock_post.call_args_list[0][1]['data'])
assert 'notification_id' in posted_json
assert 'targets' not in posted_json
assert 'message' in posted_json
assert posted_json['message'] == 'test'
assert 'title' in posted_json
assert posted_json['title'] == ''
# Reset our mock object
mock_post.reset_mock()
#
# No Batch Processing
#
# Now let's notify an object
obj = Apprise.instantiate(
'hassio://localhost/long.lived.token/serviceA:target1,target2/'
'service2/domain1.service3?batch=no')
assert isinstance(obj, NotifyHomeAssistant) is True
assert isinstance(obj.url(), str) is True
# Send Notification
assert obj.send(body="test-body", title="title") is True
# Entries are split apart
assert len(obj) == 4
assert mock_post.call_count == 4
assert mock_post.call_args_list[0][0][0] == \
'http://localhost:8123/api/services/domain1/service3'
posted_json = json.loads(mock_post.call_args_list[0][1]['data'])
assert 'notification_id' in posted_json
assert 'targets' not in posted_json
assert 'message' in posted_json
assert posted_json['message'] == 'test-body'
assert 'title' in posted_json
assert posted_json['title'] == 'title'
assert mock_post.call_args_list[1][0][0] == \
'http://localhost:8123/api/services/notify/service2'
posted_json = json.loads(mock_post.call_args_list[1][1]['data'])
assert 'notification_id' in posted_json
assert 'targets' not in posted_json
assert 'message' in posted_json
assert posted_json['message'] == 'test-body'
assert 'title' in posted_json
assert posted_json['title'] == 'title'
assert mock_post.call_args_list[2][0][0] == \
'http://localhost:8123/api/services/notify/serviceA'
posted_json = json.loads(mock_post.call_args_list[2][1]['data'])
assert 'notification_id' in posted_json
assert 'targets' in posted_json
assert isinstance(posted_json['targets'], list)
assert len(posted_json['targets']) == 1
assert 'target1' in posted_json['targets']
assert 'message' in posted_json
assert posted_json['message'] == 'test-body'
assert 'title' in posted_json
assert posted_json['title'] == 'title'
assert mock_post.call_args_list[3][0][0] == \
'http://localhost:8123/api/services/notify/serviceA'
posted_json = json.loads(mock_post.call_args_list[3][1]['data'])
assert 'notification_id' in posted_json
assert 'targets' in posted_json
assert isinstance(posted_json['targets'], list)
assert len(posted_json['targets']) == 1
assert 'target2' in posted_json['targets']
assert 'message' in posted_json
assert posted_json['message'] == 'test-body'
assert 'title' in posted_json
assert posted_json['title'] == 'title'
# Reset our mock object
mock_post.reset_mock()
#
# Batch Processing
#
# Now let's notify an object
obj = Apprise.instantiate(
'hassio://localhost/long.lived.token/serviceA:target1,target2/'
'service2/domain1.service3?batch=yes')
assert isinstance(obj, NotifyHomeAssistant) is True
assert isinstance(obj.url(), str) is True
# Send Notification
assert obj.send(body="test-body", title="title") is True
# Entries targets can be grouped
assert len(obj) == 3
assert mock_post.call_count == 3
assert mock_post.call_args_list[0][0][0] == \
'http://localhost:8123/api/services/domain1/service3'
posted_json = json.loads(mock_post.call_args_list[0][1]['data'])
assert 'notification_id' in posted_json
assert 'targets' not in posted_json
assert 'message' in posted_json
assert posted_json['message'] == 'test-body'
assert 'title' in posted_json
assert posted_json['title'] == 'title'
assert mock_post.call_args_list[1][0][0] == \
'http://localhost:8123/api/services/notify/service2'
posted_json = json.loads(mock_post.call_args_list[1][1]['data'])
assert 'notification_id' in posted_json
assert 'targets' not in posted_json
assert 'message' in posted_json
assert posted_json['message'] == 'test-body'
assert 'title' in posted_json
assert posted_json['title'] == 'title'
assert mock_post.call_args_list[2][0][0] == \
'http://localhost:8123/api/services/notify/serviceA'
posted_json = json.loads(mock_post.call_args_list[2][1]['data'])
assert 'notification_id' in posted_json
assert 'targets' in posted_json
assert isinstance(posted_json['targets'], list)
# Our batch groups our targets
assert len(posted_json['targets']) == 2
assert 'target1' in posted_json['targets']
assert 'target2' in posted_json['targets']
assert 'message' in posted_json
assert posted_json['message'] == 'test-body'
assert 'title' in posted_json
assert posted_json['title'] == 'title'
# Reset our mock object
mock_post.reset_mock()
#
# Test error handling on multi-query request
#
# Now let's notify an object
obj = Apprise.instantiate(
'hassio://localhost/long.lived.token/serviceA:target1,target2/'
'service2:target3,target4,target5,target6?batch=no')
assert isinstance(obj, NotifyHomeAssistant) is True
assert isinstance(obj.url(), str) is True
bad_response = mock.Mock()
bad_response.content = ''
bad_response.status_code = requests.codes.not_found
mock_post.side_effect = (response, bad_response)
# We will fail on our second message sent
assert obj.send(body="test-body", title="title") is False