From 9242445126faee9cb5cf07182bc0484ea6218389 Mon Sep 17 00:00:00 2001
From: Joerg Schultze-Lutter
<76180229+joergschultzelutter@users.noreply.github.com>
Date: Tue, 28 Dec 2021 18:35:03 +0100
Subject: [PATCH] Added DAPNET/Hampager Support (#506)
---
README.md | 1 +
apprise/plugins/NotifyDapnet.py | 393 +++++++++++++++++++++++++++
apprise/utils.py | 79 ++++++
packaging/redhat/python-apprise.spec | 2 +-
setup.py | 8 +-
test/helpers/rest.py | 3 +
test/test_plugin_dapnet.py | 122 +++++++++
test/test_utils.py | 80 ++++++
8 files changed, 683 insertions(+), 5 deletions(-)
create mode 100644 apprise/plugins/NotifyDapnet.py
create mode 100644 test/test_plugin_dapnet.py
diff --git a/README.md b/README.md
index 03258f01..5cbda9a2 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,7 @@ The table below identifies the services this tool supports and some example serv
| -------------------- | ---------- | ------------ | -------------- |
| [AWS SNS](https://github.com/caronc/apprise/wiki/Notify_sns) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN
| [ClickSend](https://github.com/caronc/apprise/wiki/Notify_clicksend) | clicksend:// | (TCP) 443 | clicksend://user:pass@PhoneNo
clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN
+| [DAPNET](https://github.com/caronc/apprise/wiki/Notify_dapnet) | dapnet:// | (TCP) 80 | dapnet://user:pass@callsign
dapnet://user:pass@callsign1/callsign2/callsignN
| [D7 Networks](https://github.com/caronc/apprise/wiki/Notify_d7networks) | d7sms:// | (TCP) 443 | d7sms://user:pass@PhoneNo
d7sms://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN
| [DingTalk](https://github.com/caronc/apprise/wiki/Notify_dingtalk) | dingtalk:// | (TCP) 443 | dingtalk://token/
dingtalk://token/ToPhoneNo
dingtalk://token/ToPhoneNo1/ToPhoneNo2/ToPhoneNo1/
| [Kavenegar](https://github.com/caronc/apprise/wiki/Notify_kavenegar) | kavenegar:// | (TCP) 443 | kavenegar://ApiKey/ToPhoneNo
kavenegar://FromPhoneNo@ApiKey/ToPhoneNo
kavenegar://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN
diff --git a/apprise/plugins/NotifyDapnet.py b/apprise/plugins/NotifyDapnet.py
new file mode 100644
index 00000000..ee5f0409
--- /dev/null
+++ b/apprise/plugins/NotifyDapnet.py
@@ -0,0 +1,393 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 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.
+
+# To use this plugin, sign up with Hampager (you need to be a licensed
+# ham radio operator
+# http://www.hampager.de/
+#
+# You're done at this point, you only need to know your user/pass that
+# you signed up with.
+
+# The following URLs would be accepted by Apprise:
+# - dapnet://{user}:{password}@{callsign}
+# - dapnet://{user}:{password}@{callsign1}/{callsign2}
+
+# Optional parameters:
+# - priority (NORMAL or EMERGENCY). Default: NORMAL
+# - txgroups --> comma-separated list of DAPNET transmitter
+# groups. Default: 'dl-all'
+# https://hampager.de/#/transmitters/groups
+
+from json import dumps
+
+# The API reference used to build this plugin was documented here:
+# https://hampager.de/dokuwiki/doku.php#dapnet_api
+#
+import requests
+from requests.auth import HTTPBasicAuth
+
+from .NotifyBase import NotifyBase
+from ..AppriseLocale import gettext_lazy as _
+from ..URLBase import PrivacyMode
+from ..common import NotifyType
+from ..utils import is_call_sign
+from ..utils import parse_call_sign
+from ..utils import parse_list
+from ..utils import parse_bool
+
+
+class DapnetPriority(object):
+ NORMAL = 0
+ EMERGENCY = 1
+
+
+DAPNET_PRIORITIES = (
+ DapnetPriority.NORMAL,
+ DapnetPriority.EMERGENCY,
+)
+
+
+class NotifyDapnet(NotifyBase):
+ """
+ A wrapper for DAPNET / Hampager Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'Dapnet'
+
+ # The services URL
+ service_url = 'https://hampager.de/'
+
+ # The default secure protocol
+ secure_protocol = 'dapnet'
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dapnet'
+
+ # Dapnet uses the http protocol with JSON requests
+ notify_url = 'http://www.hampager.de:8080/calls'
+
+ # The maximum length of the body
+ body_maxlen = 80
+
+ # A title can not be used for Dapnet Messages. Setting this to zero will
+ # cause any title (if defined) to get placed into the message body.
+ title_maxlen = 0
+
+ # The maximum amount of emails that can reside within a single transmission
+ default_batch_size = 50
+
+ # Define object templates
+ templates = ('{schema}://{user}:{password}@{targets}',)
+
+ # Define our template tokens
+ template_tokens = dict(
+ NotifyBase.template_tokens,
+ **{
+ 'user': {
+ 'name': _('User Name'),
+ 'type': 'string',
+ 'required': True,
+ },
+ 'password': {
+ 'name': _('Password'),
+ 'type': 'string',
+ 'private': True,
+ 'required': True,
+ },
+ 'target_callsign': {
+ 'name': _('Target Callsign'),
+ 'type': 'string',
+ 'regex': (
+ r'^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$', 'i',
+ ),
+ 'map_to': 'targets',
+ },
+ 'targets': {
+ 'name': _('Targets'),
+ 'type': 'list:string',
+ 'required': True,
+ },
+ }
+ )
+
+ # Define our template arguments
+ template_args = dict(
+ NotifyBase.template_args,
+ **{
+ 'to': {
+ 'name': _('Target Callsign'),
+ 'type': 'string',
+ 'map_to': 'targets',
+ },
+ 'priority': {
+ 'name': _('Priority'),
+ 'type': 'choice:int',
+ 'values': DAPNET_PRIORITIES,
+ 'default': DapnetPriority.NORMAL,
+ },
+ 'txgroups': {
+ 'name': _('Transmitter Groups'),
+ 'type': 'string',
+ 'default': 'dl-all',
+ 'private': True,
+ },
+ 'batch': {
+ 'name': _('Batch Mode'),
+ 'type': 'bool',
+ 'default': False,
+ },
+ }
+ )
+
+ def __init__(self, targets=None, priority=None, txgroups=None,
+ batch=False, **kwargs):
+ """
+ Initialize Dapnet Object
+ """
+ super(NotifyDapnet, self).__init__(**kwargs)
+
+ # Parse our targets
+ self.targets = list()
+
+ # get the emergency prio setting
+ if priority not in DAPNET_PRIORITIES:
+ self.priority = self.template_args['priority']['default']
+ else:
+ self.priority = priority
+
+ if not (self.user and self.password):
+ msg = 'A Dapnet user/pass was not provided.'
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ # Get the transmitter group
+ self.txgroups = parse_list(
+ NotifyDapnet.template_args['txgroups']['default']
+ if not txgroups else txgroups)
+
+ # Prepare Batch Mode Flag
+ self.batch = batch
+
+ for target in parse_call_sign(targets):
+ # Validate targets and drop bad ones:
+ result = is_call_sign(target)
+ if not result:
+ self.logger.warning(
+ 'Dropping invalid Amateur radio call sign ({}).'.format(
+ target),
+ )
+ continue
+
+ # Store callsign
+ self.targets.append(result['callsign'])
+
+ return
+
+ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
+ """
+ Perform Dapnet Notification
+ """
+
+ if not self.targets:
+ # There is no one to email; we're done
+ self.logger.warning(
+ 'There are no Amateur radio callsigns to notify')
+ return False
+
+ # Send in batches if identified to do so
+ batch_size = 1 if not self.batch else self.default_batch_size
+
+ headers = {
+ 'User-Agent': self.app_id,
+ 'Content-Type': 'application/json; charset=utf-8',
+ }
+
+ # error tracking (used for function return)
+ has_error = False
+
+ # prepare the emergency mode
+ emergency_mode = True \
+ if self.priority == DapnetPriority.EMERGENCY else False
+
+ # Create a copy of the targets list
+ targets = list(self.targets)
+
+ for index in range(0, len(targets), batch_size):
+
+ # prepare JSON payload
+ payload = {
+ 'text': body,
+ 'callSignNames': targets[index:index + batch_size],
+ 'transmitterGroupNames': self.txgroups,
+ 'emergency': emergency_mode,
+ }
+
+ self.logger.debug('DAPNET POST URL: %s' % self.notify_url)
+ self.logger.debug('DAPNET Payload: %s' % dumps(payload))
+
+ # Always call throttle before any remote server i/o is made
+ self.throttle()
+ try:
+ r = requests.post(
+ self.notify_url,
+ data=dumps(payload),
+ headers=headers,
+ auth=HTTPBasicAuth(
+ username=self.user, password=self.password),
+ verify=self.verify_certificate,
+ timeout=self.request_timeout,
+ )
+ if r.status_code != requests.codes.created:
+ # We had a problem
+
+ self.logger.warning(
+ 'Failed to send DAPNET notification {} to {}: '
+ 'error={}.'.format(
+ payload['text'],
+ ' to {}'.format(self.targets),
+ r.status_code
+ )
+ )
+
+ self.logger.debug(
+ 'Response Details:\r\n{}'.format(r.content))
+
+ # Mark our failure
+ has_error = True
+
+ else:
+ self.logger.info(
+ 'Sent \'{}\' DAPNET notification {}'.format(
+ payload['text'], 'to {}'.format(self.targets)
+ )
+ )
+
+ except requests.RequestException as e:
+ self.logger.warning(
+ 'A Connection error occurred sending DAPNET '
+ 'notification to {}'.format(self.targets)
+ )
+ self.logger.debug('Socket Exception: %s' % str(e))
+
+ # Mark our failure
+ has_error = True
+
+ return not has_error
+
+ def url(self, privacy=False, *args, **kwargs):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Define any URL parameters
+ _map = {
+ DapnetPriority.NORMAL: 'normal',
+ DapnetPriority.EMERGENCY: 'emergency',
+ }
+
+ # Define any URL parameters
+ params = {
+ 'priority': 'normal' if self.priority not in _map
+ else _map[self.priority],
+ 'batch': 'yes' if self.batch else 'no',
+ 'txgroups': ','.join(self.txgroups),
+ }
+
+ # Extend our parameters
+ params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
+
+ # Setup Authentication
+ auth = '{user}:{password}@'.format(
+ user=NotifyDapnet.quote(self.user, safe=""),
+ password=self.pprint(
+ self.password, privacy, mode=PrivacyMode.Secret, safe=''
+ ),
+ )
+
+ return '{schema}://{auth}{targets}?{params}'.format(
+ schema=self.secure_protocol,
+ auth=auth,
+ targets='/'.join([self.pprint(x, privacy, safe='')
+ for x in self.targets]),
+ params=NotifyDapnet.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
+
+ # All elements are targets
+ results['targets'] = [NotifyDapnet.unquote(results['host'])]
+
+ # All entries after the hostname are additional targets
+ results['targets'].extend(NotifyDapnet.split_path(results['fullpath']))
+
+ # Support the 'to' variable so that we can support rooms this way too
+ # The 'to' makes it easier to use yaml configuration
+ if 'to' in results['qsd'] and len(results['qsd']['to']):
+ results['targets'] += parse_call_sign(results['qsd']['to'])
+
+ # Check for priority
+ if 'priority' in results['qsd'] and len(results['qsd']['priority']):
+ _map = {
+ # Letter Assignments
+ 'n': DapnetPriority.NORMAL,
+ 'e': DapnetPriority.EMERGENCY,
+ 'no': DapnetPriority.NORMAL,
+ 'em': DapnetPriority.EMERGENCY,
+ # Numeric assignments
+ '0': DapnetPriority.NORMAL,
+ '1': DapnetPriority.EMERGENCY,
+ }
+ try:
+ results['priority'] = \
+ _map[results['qsd']['priority'][0:2].lower()]
+
+ except KeyError:
+ # No priority was set
+ pass
+
+ # Check for one or multiple transmitter groups (comma separated)
+ # and split them up, when necessary
+ if 'txgroups' in results['qsd']:
+ results['txgroups'] = \
+ [x.lower() for x in
+ NotifyDapnet.parse_list(results['qsd']['txgroups'])]
+
+ # Get Batch Mode Flag
+ results['batch'] = \
+ parse_bool(results['qsd'].get(
+ 'batch', NotifyDapnet.template_args['batch']['default']))
+
+ return results
diff --git a/apprise/utils.py b/apprise/utils.py
index 27b263c3..0c525d03 100644
--- a/apprise/utils.py
+++ b/apprise/utils.py
@@ -133,6 +133,17 @@ IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$')
PHONE_NO_DETECTION_RE = re.compile(
r'\s*([+(\s]*[0-9][0-9()\s-]+[0-9])(?=$|[\s,+(]+[0-9])', re.I)
+# A simple verification check to make sure the content specified
+# rougly conforms to a ham radio call sign before we parse it further
+IS_CALL_SIGN = re.compile(
+ r'^(?P[a-z0-9]{2,3}[0-9][a-z0-9]{3})'
+ r'(?P-[a-z0-9]{1,2})?\s*$', re.I)
+
+# Regular expression used to destinguish between multiple ham radio call signs
+CALL_SIGN_DETECTION_RE = re.compile(
+ r'\s*([a-z0-9]{2,3}[0-9][a-z0-9]{3}(?:-[a-z0-9]{1,2})?)'
+ r'(?=$|[\s,]+[a-z0-9]{4,6})', re.I)
+
# Regular expression used to destinguish between multiple URLs
URL_DETECTION_RE = re.compile(
r'([a-z0-9]+?:\/\/.*?)(?=$|[\s,]+[a-z0-9]{2,9}?:\/\/)', re.I)
@@ -372,6 +383,37 @@ def is_phone_no(phone, min_len=11):
}
+def is_call_sign(callsign):
+ """Determine if the specified entry is a ham radio call sign
+
+ Args:
+ callsign (str): The string you want to check.
+
+ Returns:
+ bool: Returns False if the address specified is not a phone number
+ """
+
+ try:
+ result = IS_CALL_SIGN.match(callsign)
+ if not result:
+ # not parseable content as it does not even conform closely to a
+ # callsign
+ return False
+
+ except TypeError:
+ # not parseable content
+ return False
+
+ ssid = result.group('ssid')
+ return {
+ # always treat call signs as uppercase content
+ 'callsign': result.group('callsign').upper(),
+ # Prevent the storing of the None keyword in the event the SSID was
+ # not detected
+ 'ssid': ssid if ssid else '',
+ }
+
+
def is_email(address):
"""Determine if the specified entry is an email address
@@ -766,6 +808,43 @@ def parse_phone_no(*args, **kwargs):
return result
+def parse_call_sign(*args, **kwargs):
+ """
+ Takes a string containing ham radio call signs separated by
+ comma and/or spacesand returns a list.
+ """
+
+ # for Python 2.7 support, store_unparsable is not in the url above
+ # as just parse_emails(*args, store_unparseable=True) since it is
+ # an invalid syntax. This is the workaround to be backards compatible:
+ store_unparseable = kwargs.get('store_unparseable', True)
+
+ result = []
+ for arg in args:
+ if isinstance(arg, six.string_types) and arg:
+ _result = CALL_SIGN_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 call signs
+ result += parse_call_sign(
+ *arg, store_unparseable=store_unparseable)
+
+ return result
+
+
def parse_emails(*args, **kwargs):
"""
Takes a string containing emails separated by comma's and/or spaces and
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index aedd3017..3030ea58 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -47,7 +47,7 @@ Apprise is a Python package for simplifying access to all of the different
notification services that are out there. Apprise opens the door and makes
it easy to access:
-Apprise API, AWS SES, AWS SNS, Boxcar, ClickSend, DingTalk, Discord, E-Mail,
+Apprise API, AWS SES, AWS SNS, Boxcar, ClickSend, DAPNET, DingTalk, Discord, E-Mail,
Emby, Faast, FCM, Flock, Gitter, Google Chat, Gotify, Growl, Home Assistant,
IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, MacOSX, Mailgun, Mattermost,
Matrix, Microsoft Windows, Microsoft Teams, MessageBird, MQTT, MSG91, MyAndroid,
diff --git a/setup.py b/setup.py
index 92a4cba4..a6838132 100755
--- a/setup.py
+++ b/setup.py
@@ -70,10 +70,10 @@ setup(
cmdclass=cmdclass,
url='https://github.com/caronc/apprise',
keywords='Push Notifications Alerts Email AWS SES SNS Boxcar ClickSend '
- 'Dingtalk Discord Dbus Emby Faast FCM Flock Gitter Gnome Google Chat '
- 'Gotify Growl Home Assistant IFTTT Join Kavenegar KODI Kumulos '
- 'LaMetric MacOS Mailgun Matrix Mattermost MessageBird MQTT MSG91 '
- 'Nexmo Nextcloud Notica Notifico Office365 OneSignal Opsgenie '
+ 'DAPNET Dingtalk Discord Dbus Emby Faast FCM Flock Gitter Gnome '
+ 'Google Chat Gotify Growl Home Assistant IFTTT Join Kavenegar KODI '
+ 'Kumulos LaMetric MacOS Mailgun Matrix Mattermost MessageBird MQTT '
+ 'MSG91 Nexmo Nextcloud Notica Notifico Office365 OneSignal Opsgenie '
'ParsePlatform PopcornNotify Prowl PushBullet Pushjet Pushed '
'Pushover PushSafer Reddit Rocket.Chat Ryver SendGrid ServerChan '
'SimplePush Sinch Slack SMTP2Go SparkPost Spontit Streamlabs '
diff --git a/test/helpers/rest.py b/test/helpers/rest.py
index 71cede28..6f039d8d 100644
--- a/test/helpers/rest.py
+++ b/test/helpers/rest.py
@@ -230,6 +230,9 @@ class AppriseURLTester(object):
assert False
if isinstance(obj, plugins.NotifyBase):
+ # Ensure we are not performing any type of thorttling
+ obj.request_rate_per_sec = 0
+
# We loaded okay; now lets make sure we can reverse
# this url
assert isinstance(obj.url(), six.string_types) is True
diff --git a/test/test_plugin_dapnet.py b/test/test_plugin_dapnet.py
new file mode 100644
index 00000000..9461238b
--- /dev/null
+++ b/test/test_plugin_dapnet.py
@@ -0,0 +1,122 @@
+# -*- 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.
+# Disable logging for a cleaner testing output
+import logging
+import requests
+
+from apprise import plugins
+from helpers import AppriseURLTester
+
+logging.disable(logging.CRITICAL)
+
+# Our Testing URLs
+apprise_url_tests = (
+ ('dapnet://', {
+ # We failed to identify any valid authentication
+ 'instance': TypeError,
+ }),
+ ('dapnet://:@/', {
+ # We failed to identify any valid authentication
+ 'instance': TypeError,
+ }),
+ ('dapnet://user:pass', {
+ # No call-sign specified
+ 'instance': TypeError,
+ }),
+ ('dapnet://user@host', {
+ # No password specified
+ 'instance': TypeError,
+ }),
+ ('dapnet://user:pass@{}'.format('DF1ABC'), {
+ # valid call sign
+ 'instance': plugins.NotifyDapnet,
+ 'requests_response_code': requests.codes.created,
+ }),
+ ('dapnet://user:pass@{}/{}'.format('DF1ABC', 'DF1DEF'), {
+ # valid call signs
+ 'instance': plugins.NotifyDapnet,
+ 'requests_response_code': requests.codes.created,
+ }),
+ ('dapnet://user:pass@?to={},{}'.format('DF1ABC', 'DF1DEF'), {
+ # support the to= argument
+ 'instance': plugins.NotifyDapnet,
+ 'requests_response_code': requests.codes.created,
+ }),
+ ('dapnet://user:pass@{}?priority=normal'.format('DF1ABC'), {
+ # valid call sign with priority
+ 'instance': plugins.NotifyDapnet,
+ 'requests_response_code': requests.codes.created,
+ }),
+ ('dapnet://user:pass@{}?priority=em&batch=false'.format(
+ '/'.join(['DF1ABC', '0A1DEF'])), {
+ # valid call sign with priority (emergency) + no batch
+ # transmissions
+ 'instance': plugins.NotifyDapnet,
+ 'requests_response_code': requests.codes.created,
+ }),
+ ('dapnet://user:pass@{}?priority=invalid'.format('DF1ABC'), {
+ # invalid priority
+ 'instance': plugins.NotifyDapnet,
+ 'requests_response_code': requests.codes.created,
+ }),
+ ('dapnet://user:pass@{}?txgroups=dl-all,all'.format('DF1ABC'), {
+ # valid call sign with two transmitter groups
+ 'instance': plugins.NotifyDapnet,
+ 'requests_response_code': requests.codes.created,
+ }),
+ ('dapnet://user:pass@{}?txgroups=invalid'.format('DF1ABC'), {
+ # valid call sign with invalid transmitter group
+ 'instance': plugins.NotifyDapnet,
+ 'requests_response_code': requests.codes.created,
+ }),
+ ('dapnet://user:pass@{}/{}'.format('abcdefghi', 'a'), {
+ # invalid call signs
+ 'instance': plugins.NotifyDapnet,
+ 'notify_response': False,
+ }),
+ # Edge cases
+ ('dapnet://user:pass@{}'.format('DF1ABC'), {
+ 'instance': plugins.NotifyDapnet,
+ # throw a bizzare code forcing us to fail to look it up
+ 'response': False,
+ 'requests_response_code': 999,
+ }),
+ ('dapnet://user:pass@{}'.format('DF1ABC'), {
+ 'instance': plugins.NotifyDapnet,
+ # Throws a series of connection and transfer exceptions when this flag
+ # is set and tests that we gracfully handle them
+ 'test_requests_exceptions': True,
+ }),
+)
+
+
+def test_plugin_dapnet_urls():
+ """
+ NotifyDapnet() Apprise URLs
+
+ """
+
+ # Run our general tests
+ AppriseURLTester(tests=apprise_url_tests).run_all()
diff --git a/test/test_utils.py b/test/test_utils.py
index f30dadb4..5d0383e1 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -751,6 +751,44 @@ def test_is_email():
assert utils.is_email("Name ") is False
+def test_is_call_sign_no():
+ """
+ API: is_call_sign() function
+
+ """
+ # Invalid numbers
+ assert utils.is_call_sign(None) is False
+ assert utils.is_call_sign(42) is False
+ assert utils.is_call_sign(object) is False
+ assert utils.is_call_sign('') is False
+ assert utils.is_call_sign('1') is False
+ assert utils.is_call_sign('12') is False
+ assert utils.is_call_sign('abc') is False
+ assert utils.is_call_sign('+()') is False
+ assert utils.is_call_sign('+') is False
+ assert utils.is_call_sign(None) is False
+ assert utils.is_call_sign(42) is False
+
+ # To short or 2 long
+ assert utils.is_call_sign('DF1AB') is False
+ assert utils.is_call_sign('DF1ABCX') is False
+ assert utils.is_call_sign('DF1ABCEFG') is False
+ assert utils.is_call_sign('1ABCX') is False
+ # 4th character is not an number
+ assert utils.is_call_sign('XXXXXX') is False
+
+ # Some valid checks
+ result = utils.is_call_sign('DF1ABC')
+ assert isinstance(result, dict)
+ assert 'DF1ABC' == result['callsign']
+ assert '' == result['ssid']
+
+ # Get our SSID
+ result = utils.is_call_sign('DF1ABC-14')
+ assert 'DF1ABC' == result['callsign']
+ assert '-14' == result['ssid']
+
+
def test_is_phone_no():
"""
API: is_phone_no() function
@@ -861,6 +899,48 @@ def test_is_phone_no():
assert '18001234567' == results['full']
+def test_parse_call_sign():
+ """utils: parse_call_sign() testing """
+ # A simple single array entry (As str)
+ results = utils.parse_call_sign('')
+ assert isinstance(results, list)
+ assert len(results) == 0
+
+ # just delimeters
+ results = utils.parse_call_sign(', ,, , ,,, ')
+ assert isinstance(results, list)
+ assert len(results) == 0
+
+ results = utils.parse_call_sign(None)
+ assert isinstance(results, list)
+ assert len(results) == 0
+
+ results = utils.parse_call_sign(42)
+ assert isinstance(results, list)
+ assert len(results) == 0
+
+ results = utils.parse_call_sign('this is not a parseable call sign at all')
+ assert isinstance(results, list)
+ assert len(results) == 9
+
+ results = utils.parse_call_sign(
+ 'this is not a parseable call sign at all', store_unparseable=False)
+ assert isinstance(results, list)
+ assert len(results) == 0
+
+ # Now test valid call signs
+ results = utils.parse_call_sign('0A1DEF')
+ assert isinstance(results, list)
+ assert len(results) == 1
+ assert '0A1DEF' in results
+
+ results = utils.parse_call_sign('0A1DEF, DF1ABC')
+ assert isinstance(results, list)
+ assert len(results) == 2
+ assert '0A1DEF' in results
+ assert 'DF1ABC' in results
+
+
def test_parse_phone_no():
"""utils: parse_phone_no() testing """
# A simple single array entry (As str)