APRS (Automated Packet Reporting System) Ham Radio plugin (#1005)

This commit is contained in:
Joerg Schultze-Lutter 2023-12-16 04:06:12 +01:00 committed by GitHub
parent 76831f9a8b
commit d7166a270f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 1110 additions and 12 deletions

1
.gitignore vendored
View File

@ -69,3 +69,4 @@ target/
.project
.pydevproject
.settings
.DS_Store

View File

@ -0,0 +1,741 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# To use this plugin, you need to be a licensed ham radio operator
#
# Plugin constraints:
#
# - message length = 67 chars max.
# - message content = ASCII 7 bit
# - APRS messages will be sent without msg ID, meaning that
# ham radio operators cannot acknowledge them
# - Bring your own APRS-IS passcode. If you don't know what
# this is or how to get it, then this plugin is not for you
# - Do NOT change the Device/ToCall ID setting UNLESS this
# module is used outside of Apprise. This identifier helps
# the ham radio community with determining the software behind
# a given APRS message.
# - With great (ham radio) power comes great responsibility; do
# not use this plugin for spamming other ham radio operators
#
# In order to digest text input which is not in plain English,
# users can install the optional 'unidecode' package as part
# of their venv environment. Details: see plugin description
#
#
# 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:
# - aprs://{user}:{password}@{callsign}
# - aprs://{user}:{password}@{callsign1}/{callsign2}
# Optional parameters:
# - locale --> APRS-IS target server to connect with
# Default: EURO --> 'euro.aprs2.net'
# Details: https://www.aprs2.net/
#
# APRS message format specification:
# http://www.aprs.org/doc/APRS101.PDF
#
import socket
import sys
from itertools import chain
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 .. import __version__
import re
# Fixed APRS-IS server locales
# Default is 'EURO'
# See https://www.aprs2.net/ for details
# Select the rotating server in case you
# don"t care about a specific locale
APRS_LOCALES = {
"NOAM": "noam.aprs2.net",
"SOAM": "soam.aprs2.net",
"EURO": "euro.aprs2.net",
"ASIA": "asia.aprs2.net",
"AUNZ": "aunz.aprs2.net",
"ROTA": "rotate.aprs2.net",
}
# Identify all unsupported characters
APRS_BAD_CHARMAP = {
r"Ä": "Ae",
r"Ö": "Oe",
r"Ü": "Ue",
r"ä": "ae",
r"ö": "oe",
r"ü": "ue",
r"ß": "ss",
}
# Our compiled mapping of bad characters
APRS_COMPILED_MAP = re.compile(
r'(' + '|'.join(APRS_BAD_CHARMAP.keys()) + r')')
class NotifyAprs(NotifyBase):
"""
A wrapper for APRS Notifications via APRS-IS
"""
# The default descriptive name associated with the Notification
service_name = "Aprs"
# The services URL
service_url = "https://www.aprs2.net/"
# The default secure protocol
secure_protocol = "aprs"
# A URL that takes you to the setup/help of the specific protocol
setup_url = "https://github.com/caronc/apprise/wiki/Notify_aprs"
# APRS default port, supported by all core servers
# Details: https://www.aprs-is.net/Connecting.aspx
notify_port = 10152
# The maximum length of the APRS message body
body_maxlen = 67
# Apprise APRS Device ID / TOCALL ID
# This is a FIXED value which is associated with this plugin.
# Its value MUST NOT be changed. If you use this APRS plugin
# code OUTSIDE of Apprise, please request your own TOCALL ID.
# Details: see https://github.com/aprsorg/aprs-deviceid
#
# Do NOT use the generic "APRS" TOCALL ID !!!!!
#
device_id = "APPRIS"
# A title can not be used for APRS Messages. Setting this to zero will
# cause any title (if defined) to get placed into the message body.
title_maxlen = 0
# Helps to reduce the number of login-related errors where the
# APRS-IS server "isn't ready yet". If we try to receive the rx buffer
# without this grace perid in place, we may receive "incomplete" responses
# where the login response lacks information. In case you receive too many
# "Rx: APRS-IS msg is too short - needs to have at least two lines" error
# messages, you might want to increase this value to a larger time span
# Per previous experience, do not use values lower than 0.5 (seconds)
request_rate_per_sec = 0.8
# Encoding of retrieved content
aprs_encoding = 'latin-1'
# 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",
},
"locale": {
"name": _("Locale"),
"type": "choice:string",
"values": APRS_LOCALES,
"default": "EURO",
},
}
)
def __init__(self, targets=None, locale=None, **kwargs):
"""
Initialize APRS Object
"""
super().__init__(**kwargs)
# Our (future) socket sobject
self.sock = None
# Parse our targets
self.targets = list()
"""
Check if the user has provided credentials
"""
if not (self.user and self.password):
msg = "An APRS user/pass was not provided."
self.logger.warning(msg)
raise TypeError(msg)
"""
Check if the user tries to use a read-only access
to APRS-IS. We need to send content, meaning that
read-only access will not work
"""
if self.password == "-1":
msg = "APRS read-only passwords are not supported."
self.logger.warning(msg)
raise TypeError(msg)
"""
Check if the password is numeric
"""
if not self.password.isnumeric():
msg = "Invalid APRS-IS password"
self.logger.warning(msg)
raise TypeError(msg)
"""
Convert given user name (FROM callsign) and
device ID to to uppercase
"""
self.user = self.user.upper()
self.device_id = self.device_id.upper()
"""
Check if the user has provided a locale for the
APRS-IS-server and validate it, if necessary
"""
if locale:
if locale.upper() not in APRS_LOCALES:
msg = (
"Unsupported APRS-IS server locale. "
"Received: {}. Valid: {}".format(
locale, ", ".join(str(x) for x in APRS_LOCALES.keys())
)
)
self.logger.warning(msg)
raise TypeError(msg)
# Set the transmitter group
self.locale = \
NotifyAprs.template_args["locale"]["default"] \
if not locale else locale.upper()
# Used for URL generation afterwards only
self.invalid_targets = list()
for target in parse_call_sign(targets):
# Validate targets and drop bad ones
# We just need to know if the call sign (including SSID, if
# provided) is valid and can then process the input as is
result = is_call_sign(target)
if not result:
self.logger.warning(
"Dropping invalid Amateur radio call sign ({}).".format(
target
),
)
self.invalid_targets.append(target.upper())
continue
# Store entry
self.targets.append(target.upper())
return
def socket_close(self):
"""
Closes the socket connection whereas present
"""
if self.sock:
try:
self.sock.close()
except Exception:
# No worries if socket exception thrown on close()
pass
self.sock = None
def socket_open(self):
"""
Establishes the connection to the APRS-IS
socket server
"""
self.logger.debug(
"Creating socket connection with APRS-IS {}:{}".format(
APRS_LOCALES[self.locale], self.notify_port
)
)
try:
self.sock = socket.create_connection(
(APRS_LOCALES[self.locale], self.notify_port),
self.socket_connect_timeout,
)
except ConnectionError as e:
self.logger.debug("Socket Exception socket_open: %s", str(e))
self.sock = None
return False
except socket.gaierror as e:
self.logger.debug("Socket Exception socket_open: %s", str(e))
self.sock = None
return False
except socket.timeout as e:
self.logger.debug(
"Socket Timeout Exception socket_open: %s", str(e))
self.sock = None
return False
except Exception as e:
self.logger.debug("General Exception socket_open: %s", str(e))
self.sock = None
return False
# We are connected.
# getpeername() is not supported by every OS. Therefore,
# we MAY receive an exception even though we are
# connected successfully.
try:
# Get the physical host/port of the server
host, port = self.sock.getpeername()
# and create debug info
self.logger.debug("Connected to {}:{}".format(host, port))
except ValueError:
# Seens as if we are running on an operating
# system that does not support getpeername()
# Create a minimal log file entry
self.logger.debug("Connected to APRS-IS")
# Return success
return True
def aprsis_login(self):
"""
Generate the APRS-IS login string, send it to the server
and parse the response
Returns True/False wrt whether the login was successful
"""
self.logger.debug("socket_login: init")
# Check if we are connected
if not self.sock:
self.logger.warning("socket_login: Not connected to APRS-IS")
return False
# APRS-IS login string, see https://www.aprs-is.net/Connecting.aspx
login_str = "user {0} pass {1} vers apprise {2}\r\n".format(
self.user, self.password, __version__
)
# Send the data & abort in case of error
if not self.socket_send(login_str):
self.logger.warning(
"socket_login: Login to APRS-IS unsuccessful,"
" exception occurred"
)
self.socket_close()
return False
rx_buf = self.socket_receive(len(login_str) + 100)
# Abort the remaining process in case an error has occurred
if not rx_buf:
self.logger.warning(
"socket_login: Login to APRS-IS "
"unsuccessful, exception occurred"
)
self.socket_close()
return False
# APRS-IS sends at least two lines of data
# The data that we need is in line #2 so
# let's split the content and see what we have
rx_lines = rx_buf.splitlines()
if len(rx_lines) < 2:
self.logger.warning(
"socket_login: APRS-IS msg is too short"
" - needs to have at least two lines"
)
self.socket_close()
return False
# Now split the 2nd line's content and extract
# both call sign and login status
try:
_, _, callsign, status, _ = rx_lines[1].split(" ", 4)
except ValueError:
# ValueError is returned if there were not enough elements to
# populate the response
self.logger.warning(
"socket_login: " "received invalid response from APRS-IS"
)
self.socket_close()
return False
if callsign != self.user:
self.logger.warning(
"socket_login: " "call signs differ: %s" % callsign
)
self.socket_close()
return False
if status.startswith("unverified"):
self.logger.warning(
"socket_login: "
"invalid APRS-IS password for given call sign"
)
self.socket_close()
return False
# all validations are successful; we are connected
return True
def socket_send(self, tx_data):
"""
Generic "Send data to a socket"
"""
self.logger.debug("socket_send: init")
# Check if we are connected
if not self.sock:
self.logger.warning("socket_send: Not connected to APRS-IS")
return False
# Encode our data if we are on Python3 or later
payload = (
tx_data.encode("utf-8") if sys.version_info[0] >= 3 else tx_data
)
# Always call throttle before any remote server i/o is made
self.throttle()
# Try to open the socket
# Send the content to APRS-IS
try:
self.sock.setblocking(True)
self.sock.settimeout(self.socket_connect_timeout)
self.sock.sendall(payload)
except socket.gaierror as e:
self.logger.warning("Socket Exception socket_send: %s" % str(e))
self.sock = None
return False
except socket.timeout as e:
self.logger.warning(
"Socket Timeout Exception " "socket_send: %s" % str(e)
)
self.sock = None
return False
except Exception as e:
self.logger.warning(
"General Exception " "socket_send: %s" % str(e)
)
self.sock = None
return False
self.logger.debug("socket_send: successful")
# mandatory on several APRS-IS servers
# helps to reduce the number of errors where
# the server only returns an abbreviated message
return True
def socket_reset(self):
"""
Resets the socket's buffer
"""
self.logger.debug("socket_reset: init")
_ = self.socket_receive(0)
self.logger.debug("socket_reset: successful")
return True
def socket_receive(self, rx_len):
"""
Generic "Receive data from a socket"
"""
self.logger.debug("socket_receive: init")
# Check if we are connected
if not self.sock:
self.logger.warning("socket_receive: not connected to APRS-IS")
return False
# len is zero in case we intend to
# reset the socket
if rx_len > 0:
self.logger.debug("socket_receive: Receiving data from APRS-IS")
# Receive content from the socket
try:
self.sock.setblocking(False)
self.sock.settimeout(self.socket_connect_timeout)
rx_buf = self.sock.recv(rx_len)
except socket.gaierror as e:
self.logger.warning(
"Socket Exception socket_receive: %s" % str(e)
)
self.sock = None
return False
except socket.timeout as e:
self.logger.warning(
"Socket Timeout Exception " "socket_receive: %s" % str(e)
)
self.sock = None
return False
except Exception as e:
self.logger.warning(
"General Exception " "socket_receive: %s" % str(e)
)
self.sock = None
return False
rx_buf = (
rx_buf.decode(self.aprs_encoding)
if sys.version_info[0] >= 3 else rx_buf
)
# There will be no data in case we reset the socket
if rx_len > 0:
self.logger.debug("Received content: {}".format(rx_buf))
self.logger.debug("socket_receive: successful")
return rx_buf.rstrip()
def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):
"""
Perform APRS Notification
"""
if not self.targets:
# There is no one to notify; we're done
self.logger.warning(
"There are no amateur radio call signs to notify"
)
return False
# prepare payload
payload = body
# sock object is "None" if we were unable to establish a connection
# In case of errors, the error message has already been sent
# to the logger object
if not self.socket_open():
return False
# We have established a successful connection
# to the socket server. Now send the login information
if not self.aprsis_login():
return False
# Login & authorization confirmed
# reset what is in our buffer
self.socket_reset()
# error tracking (used for function return)
has_error = False
# Create a copy of the targets list
targets = list(self.targets)
self.logger.debug("Starting Payload setup")
# Prepare the outgoing message
# Due to APRS's contraints, we need to do
# a lot of filtering before we can send
# the actual message
#
# First remove all characters from the
# payload that would break APRS
# see https://www.aprs.org/doc/APRS101.PDF pg. 71
payload = re.sub("[{}|~]+", "", payload)
payload = (
APRS_COMPILED_MAP.sub(
lambda x: APRS_BAD_CHARMAP[x.group()], payload)
)
# Finally, constrain output string to 67 characters as
# APRS messages are limited in length
payload = payload[:67]
# Our outgoing message MUST end with a CRLF so
# let's amend our payload respectively
payload = payload.rstrip("\r\n") + "\r\n"
self.logger.debug("Payload setup complete: {}".format(payload))
# send the message to our target call sign(s)
for index in range(0, len(targets)):
# prepare the output string
# Format:
# Device ID/TOCALL - our call sign - target call sign - body
buffer = "{}>{}::{:9}:{}".format(
self.user, self.device_id, targets[index], payload
)
# and send the content to the socket
# Note that there will be no response from APRS and
# that all exceptions are handled within the 'send' method
self.logger.debug("Sending APRS message: {}".format(buffer))
# send the content
if not self.socket_send(buffer):
has_error = True
break
# Finally, reset our socket buffer
# we DO NOT read from the socket as we
# would simply listen to the default APRS-IS stream
self.socket_reset()
self.logger.debug("Closing socket.")
self.socket_close()
self.logger.info(
"Sent %d/%d APRS-IS notification(s)", index + 1, len(targets))
return not has_error
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {}
if self.locale != NotifyAprs.template_args["locale"]["default"]:
# Store our locale if not default
params['locale'] = self.locale
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Setup Authentication
auth = "{user}:{password}@".format(
user=NotifyAprs.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(chain(
[self.pprint(x, privacy, safe="") for x in self.targets],
[self.pprint(x, privacy, safe="")
for x in self.invalid_targets],
)),
params=NotifyAprs.urlencode(params),
)
def __len__(self):
"""
Returns the number of targets associated with this notification
"""
targets = len(self.targets)
return targets if targets > 0 else 1
def __del__(self):
"""
Ensure we close any lingering connections
"""
self.socket_close()
@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"] = [NotifyAprs.unquote(results["host"])]
# All entries after the hostname are additional targets
results["targets"].extend(NotifyAprs.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"] += NotifyAprs.parse_list(results["qsd"]["to"])
# Set our APRS-IS server locale's key value and convert it to uppercase
if "locale" in results["qsd"] and len(results["qsd"]["locale"]):
results["locale"] = NotifyAprs.unquote(
results["qsd"]["locale"]
).upper()
return results

View File

@ -39,18 +39,19 @@ 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, Bark, Boxcar, Burst SMS, BulkSMS, ClickSend,
DAPNET, DingTalk, Discord, E-Mail, Emby, Faast, FCM, Flock, Google Chat,
Gotify, Growl, Guilded, Home Assistant, IFTTT, Join, Kavenegar, KODI, Kumulos,
LaMetric, Line, MacOSX, Mailgun, Mastodon, Mattermost, Matrix, MessageBird,
Microsoft Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo,
Nextcloud, NextcloudTalk, Notica, Notifiarr, Notifico, ntfy, Office365,
OneSignal, Opsgenie, PagerDuty, PagerTree, ParsePlatform, PopcornNotify,
Prowl, Pushalot, PushBullet, Pushjet, PushMe, Pushover, PushSafer, Pushy,
PushDeer, Reddit, Rocket.Chat, RSyslog, SendGrid, ServerChan, Signal,
SimplePush, Sinch, Slack, SMSEagle, SMTP2Go, Spontit, SparkPost, Super Toasty,
Streamlabs, Stride, Synology Chat, Syslog, Techulus Push, Telegram, Threema
Gateway, Twilio, Twitter, Twist, XBMC, Voipms, Vonage, WhatsApp, Webex Teams}
Apprise API, APRS, AWS SES, AWS SNS, Bark, Boxcar, Burst SMS, BulkSMS,
ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby, Faast, FCM, Flock,
Google Chat, Gotify, Growl, Guilded, Home Assistant, IFTTT, Join, Kavenegar,
KODI, Kumulos, LaMetric, Line, MacOSX, Mailgun, Mastodon, Mattermost,
Matrix, MessageBird, Microsoft Windows, Microsoft Teams, Misskey, MQTT,
MSG91, MyAndroid, Nexmo, Nextcloud, NextcloudTalk, Notica, Notifiarr,
Notifico, ntfy, Office365, OneSignal, Opsgenie, PagerDuty, PagerTree,
ParsePlatform, PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet, PushMe,
Pushover, PushSafer, Pushy, PushDeer, Reddit, Rocket.Chat, RSyslog, SendGrid,
ServerChan, Signal, SimplePush, Sinch, Slack, SMSEagle, SMTP2Go, Spontit,
SparkPost, Super Toasty, Streamlabs, Stride, Synology Chat, Syslog,
Techulus Push, Telegram, Threema Gateway, Twilio, Twitter, Twist, XBMC,
Voipms, Vonage, WhatsApp, Webex Teams}
Name: python-%{pypi_name}
Version: 1.6.0

355
test/test_plugin_aprs.py Normal file
View File

@ -0,0 +1,355 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from unittest import mock
import socket
import apprise
from apprise.plugins.NotifyAprs import NotifyAprs
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
@mock.patch('socket.create_connection')
def test_plugin_aprs_urls(mock_create_connection):
"""
NotifyAprs() Apprise URLs
"""
# A socket object
sobj = mock.Mock()
sobj.return_value = 1
sobj.getpeername.return_value = ('localhost', 1234)
sobj.socket_close.return_value = None
sobj.setblocking.return_value = True
sobj.recv.return_value = \
'ping\npong pong DF1JSL-15 verified pong'.encode('latin-1')
sobj.sendall.return_value = True
sobj.settimeout.return_value = True
# Prepare Mock
mock_create_connection.return_value = sobj
# Test invalid URLs
assert apprise.Apprise.instantiate("aprs://") is None
assert apprise.Apprise.instantiate("aprs://:@/") is None
# No call-sign specified
assert apprise.Apprise.instantiate("aprs://DF1JSL-15:12345") is None
# Garbage
assert NotifyAprs.parse_url(None) is None
# Valid call-sign but no password
assert apprise.Apprise.instantiate(
"aprs://DF1JSL-15:@DF1ABC") is None
assert apprise.Apprise.instantiate(
"aprs://DF1JSL-15@DF1ABC") is None
# Password of -1 not supported
assert apprise.Apprise.instantiate(
"aprs://DF1JSL-15:-1@DF1ABC") is None
# Alpha Password not supported
assert apprise.Apprise.instantiate(
"aprs://DF1JSL-15:abcd@DF1ABC") is None
# Valid instances
instance = apprise.Apprise.instantiate(
"aprs://DF1JSL-15:12345@DF1ABC")
assert isinstance(instance, NotifyAprs)
assert instance.url(privacy=True).startswith(
'aprs://DF1JSL-15:****@D...C?')
assert instance.notify('test') is True
instance = apprise.Apprise.instantiate(
"aprs://DF1JSL-15:12345@DF1ABC/DF1DEF")
assert isinstance(instance, NotifyAprs)
assert instance.url(privacy=True).startswith(
'aprs://DF1JSL-15:****@D...C/D...F?')
assert instance.notify('test') is True
instance = apprise.Apprise.instantiate(
"aprs://DF1JSL-15:12345@DF1ABC-1/DF1ABC/DF1ABC-15")
assert isinstance(instance, NotifyAprs)
assert instance.url(privacy=True).startswith(
'aprs://DF1JSL-15:****@D...1/D...C/D...5?')
assert instance.notify('test') is True
instance = apprise.Apprise.instantiate(
"aprs://DF1JSL-15:12345@?to=DF1ABC,DF1DEF")
assert isinstance(instance, NotifyAprs)
assert instance.url(privacy=True).startswith(
'aprs://DF1JSL-15:****@D...C/D...F?')
assert instance.notify('test') is True
# Test Locale settings
instance = apprise.Apprise.instantiate(
"aprs://DF1JSL-15:12345@DF1ABC?locale=EURO")
assert isinstance(instance, NotifyAprs)
assert instance.url(privacy=True).startswith(
'aprs://DF1JSL-15:****@D...C?')
# we used the default locale, so no setting
assert 'locale=' not in instance.url(privacy=True)
assert instance.notify('test') is True
instance = apprise.Apprise.instantiate(
"aprs://DF1JSL-15:12345@DF1ABC?locale=NOAM")
assert isinstance(instance, NotifyAprs)
assert instance.url(privacy=True).startswith(
'aprs://DF1JSL-15:****@D...C?')
# locale is set in URL
assert 'locale=NOAM' in instance.url(privacy=True)
assert instance.notify('test') is True
# Invalid locale
assert apprise.Apprise.instantiate(
"aprs://DF1JSL-15:12345@DF1ABC?locale=invalid") is None
# Invalid call signs
instance = apprise.Apprise.instantiate(
"aprs://DF1JSL-15:12345@abcdefghi/a")
# We still instantiate
assert isinstance(instance, NotifyAprs)
# We still load our bad entries
assert instance.url(privacy=True).startswith(
"aprs://DF1JSL-15:****@A...I/A...A?")
# But with only bad entries, we have nothing to notify
assert instance.notify('test') is False
# Enforces a close
del instance
@mock.patch('socket.create_connection')
def test_plugin_aprs_edge_cases(mock_create_connection):
"""
NotifyAprs() Edge Cases
"""
# A socket object
sobj = mock.Mock()
sobj.return_value = 1
sobj.getpeername.return_value = ('localhost', 1234)
sobj.socket_close.return_value = None
sobj.setblocking.return_value = True
sobj.recv.return_value = \
'ping\npong pong DF1JSL-15 verified pong'.encode('latin-1')
sobj.sendall.return_value = True
sobj.settimeout.return_value = True
# Prepare Mock
mock_create_connection.return_value = sobj
# Valid instances
instance = apprise.Apprise.instantiate(
"aprs://DF1JSL-15:12345@DF1ABC/DF1DEF")
assert isinstance(instance, NotifyAprs)
# Objects read
assert len(instance) == 2
# Bad data
sobj.recv.return_value = 'one line'.encode('latin-1')
assert instance.notify(body='body', title='title') is False
sobj.recv.return_value = '\n\n\n'.encode('latin-1')
assert instance.notify(body='body', title='title') is False
sobj.recv.return_value = ''.encode('latin-1')
assert instance.notify(body='body', title='title') is False
sobj.recv.return_value = '\ndata'.encode('latin-1')
assert instance.notify(body='body', title='title') is False
# Different Call-Sign then what we logged in as
sobj.recv.return_value = \
'ping\npong pong DF1JSL-14 verified, pong'.encode('latin-1')
assert instance.notify(body='body', title='title') is False
# Unverified
sobj.recv.return_value = \
'ping\npong pong DF1JSL-15 unverified, pong'.encode('latin-1')
assert instance.notify(body='body', title='title') is False
#
# Test Login edge cases
#
sobj.return_value = False
assert instance.aprsis_login() is False
sobj.return_value = 1
sobj.recv.return_value = ''.encode('latin-1')
assert instance.aprsis_login() is False
sobj.recv.return_value = \
'ping\npong pong DF1JSL-15 verified pong'.encode('latin-1')
#
# Test Socket Send Exceptions
#
sobj.sendall.return_value = None
sobj.sendall.side_effect = socket.gaierror('gaierror')
# No connection
assert instance.socket_send('data') is False
# Ensure we have a connection before calling socket_send()
assert instance.socket_open() is True
assert instance.socket_send('data') is False
sobj.sendall.side_effect = socket.timeout('timeout')
assert instance.socket_open() is True
assert instance.socket_send('data') is False
assert instance.socket_open() is True
sobj.sendall.side_effect = socket.error('error')
assert instance.socket_send('data') is False
# Login is impacted by socket_send
sobj.return_value = 1
assert instance.socket_open() is True
assert instance.aprsis_login() is False
# Return some of our
sobj.sendall.side_effect = None
sobj.sendall.return_value = True
assert instance.socket_open() is True
sobj.close.return_value = None
sobj.close.side_effect = socket.gaierror('gaierror')
instance.socket_close()
sobj.close.side_effect = socket.timeout('timeout')
instance.socket_close()
sobj.close.side_effect = socket.error('error')
instance.socket_close()
sobj.return_value = None
instance.socket_close()
# Socket isn't open; so we can't get content
assert instance.socket_receive(100) is False
sobj.close.side_effect = None
sobj.close.return_value = None
# Double close test
instance.socket_close()
sobj.return_value = 1
mock_create_connection.return_value = None
mock_create_connection.side_effect = socket.gaierror('gaierror')
assert instance.socket_open() is False
assert instance.notify('test') is False
mock_create_connection.side_effect = socket.timeout('timeout')
assert instance.socket_open() is False
assert instance.notify('test') is False
mock_create_connection.side_effect = socket.error('error')
assert instance.socket_open() is False
assert instance.notify('test') is False
mock_create_connection.side_effect = ConnectionError('ConnectionError')
assert instance.socket_open() is False
assert instance.notify('test') is False
# Restore our good connection
mock_create_connection.return_value = sobj
mock_create_connection.side_effect = None
# Functionality has been restored
assert instance.socket_open() is True
# Now play with getpeername
sobj.getpeername.return_value = None
sobj.getpeername.side_effect = ValueError('getpeername ValueError')
assert instance.socket_open() is True
sobj.getpeername.return_value = ('localhost', 1234)
assert instance.socket_open() is True
# Test different receive settings
assert instance.socket_receive(0)
assert instance.socket_receive(-1)
assert instance.socket_receive(100)
sobj.recv.side_effect = socket.gaierror('gaierror')
assert instance.socket_open() is True
assert instance.socket_receive(100) is False
sobj.recv.side_effect = socket.timeout('timeout')
assert instance.socket_open() is True
assert instance.socket_receive(100) is False
sobj.recv.side_effect = socket.error('error')
assert instance.socket_open() is True
assert instance.socket_receive(100) is False
# Restore
sobj.recv.side_effect = None
sobj.recv.return_value = \
'ping\npong pong DF1JSL-15 verified pong'.encode('latin-1')
# Simulate a successful connection, but a failed notification
# To do this we need to have a login succeed, but the second call to send
# to fail
sobj.sendall.return_value = True
assert instance.notify('test') is True
sobj.sendall.return_value = None
sobj.sendall.side_effect = (True, socket.gaierror('gaierror'))
assert instance.notify('test') is False
sobj.sendall.return_value = True
sobj.sendall.side_effect = None
del sobj
def test_plugin_aprs_config_files():
"""
NotifyAprs() Config File Cases
"""
content = """
urls:
- aprs://DF1JSL-15:12345@DF1ABC":
- locale: NOAM
- aprs://DF1JSL-15:12345@DF1ABC:
- locale: SOAM
- aprs://DF1JSL-15:12345@DF1ABC:
- locale: EURO
- aprs://DF1JSL-15:12345@DF1ABC:
- locale: ASIA
- aprs://DF1JSL-15:12345@DF1ABC:
- locale: AUNZ
- aprs://DF1JSL-15:12345@DF1ABC:
- locale: ROTA
# This will fail to load because the locale is bad
- aprs://DF1JSL-15:12345@DF1ABC:
- locale: aprs_invalid
"""
# Create ourselves a config object
ac = apprise.AppriseConfig()
assert ac.add_config(content=content) is True
aobj = apprise.Apprise()
# Add our configuration
aobj.add(ac)
assert len(ac.servers()) == 6
assert len(aobj) == 6