# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2024, Chris Caron # # 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 = ( # pragma: no branch 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