From 1a1fcba1c4152d2d6a869d306d6450bda087c1ee Mon Sep 17 00:00:00 2001 From: Hemna Date: Wed, 14 Jul 2021 20:50:41 -0400 Subject: [PATCH] Add new watchlist feature This patch adds a new optional feature called Watch list. Aprsd will filter IN all aprs packets from a list of callsigns. APRSD will keep track of the last time a callsign has been seen. When the configured timeout value has been reached, the next time a callsign is seen, APRSD will send the next packet from that callsign through the new notification plugins list. The new BaseNotifyPlugin is the default core APRSD notify based plugin. When it gets a packet it will construct a reply message to be sent to the configured alert callsign to alert them that the seen callsign is now on the APRS network. This basically acts as a notification that your watched callsign list is available on APRS. The new configuration options: aprsd: watch_list: # The callsign to send a message to once a watch list callsign # is now seen on APRS-IS alert_callsign: NOCALL # The time in seconds to wait for notification. # The default is 12 hours. alert_time_seconds: 43200 # The list of callsigns to watch for callsigns: - WB4BOR - KFART # Enable/disable this feature enabled: false # The list of notify based plugins to load for # processing a new seen packet from a callsign. enabled_plugins: - aprsd.plugins.notify.BaseNotifyPlugin This patch also adds a new section in the Admin UI for showing the watch list and the age of the last seen packet for each callsing since APRSD startup. --- aprsd/client.py | 2 +- aprsd/flask.py | 30 +++ aprsd/listen.py | 390 +++++++++++++++++++++++++++++++++ aprsd/main.py | 16 +- aprsd/messaging.py | 3 +- aprsd/packets.py | 18 ++ aprsd/plugin.py | 150 ++++++++++--- aprsd/plugins/email.py | 9 +- aprsd/plugins/fortune.py | 9 +- aprsd/plugins/location.py | 8 +- aprsd/plugins/notify.py | 23 ++ aprsd/plugins/ping.py | 7 +- aprsd/plugins/query.py | 8 +- aprsd/plugins/stock.py | 8 +- aprsd/plugins/time.py | 15 +- aprsd/plugins/version.py | 7 +- aprsd/plugins/weather.py | 28 ++- aprsd/stats.py | 13 +- aprsd/threads.py | 164 ++++++++++++-- aprsd/utils.py | 36 ++- aprsd/web/static/js/charts.js | 12 +- aprsd/web/templates/index.html | 10 + setup.cfg | 1 + tests/test_plugin.py | 100 ++++++--- 24 files changed, 948 insertions(+), 119 deletions(-) create mode 100644 aprsd/listen.py create mode 100644 aprsd/plugins/notify.py diff --git a/aprsd/client.py b/aprsd/client.py index 92e9c98..613ddcb 100644 --- a/aprsd/client.py +++ b/aprsd/client.py @@ -226,7 +226,7 @@ class Aprsdis(aprslib.IS): except ParseError as exp: self.logger.log(11, "%s\n Packet: %s", exp.args[0], exp.args[1]) except UnknownFormat as exp: - self.logger.log(9, "%s\n Packet: %s", exp.args[0], exp.args[1]) + self.logger.log(9, "unknown format %s", exp.args) except LoginError as exp: self.logger.error("%s: %s", exp.__class__.__name__, exp.args[0]) except (KeyboardInterrupt, SystemExit): diff --git a/aprsd/flask.py b/aprsd/flask.py index 7714cb1..2596ac7 100644 --- a/aprsd/flask.py +++ b/aprsd/flask.py @@ -46,12 +46,30 @@ class APRSDFlask(flask_classful.FlaskView): @auth.login_required def index(self): stats = self._stats() + LOG.debug( + "watch list? {}".format( + self.config["aprsd"]["watch_list"], + ), + ) + if "watch_list" in self.config["aprsd"] and self.config["aprsd"][ + "watch_list" + ].get("enabled", False): + watch_count = len(self.config["aprsd"]["watch_list"]["callsigns"]) + watch_age = self.config["aprsd"]["watch_list"]["alert_time_seconds"] + age_time = {"seconds": watch_age} + watch_age = datetime.timedelta(**age_time) + else: + watch_count = 0 + watch_age = 0 + return flask.render_template( "index.html", initial_stats=stats, callsign=self.config["aprs"]["login"], version=aprsd.__version__, config_json=json.dumps(self.config), + watch_count=watch_count, + watch_age=watch_age, ) @auth.login_required @@ -92,6 +110,18 @@ class APRSDFlask(flask_classful.FlaskView): stats_dict = stats_obj.stats() + # Convert the watch_list entries to age + watch_list = stats_dict["aprsd"]["watch_list"] + new_list = {} + for call in watch_list: + call_date = datetime.datetime.strptime( + watch_list[call], + "%Y-%m-%d %H:%M:%S.%f", + ) + new_list[call] = str(now - call_date) + + stats_dict["aprsd"]["watch_list"] = new_list + result = { "time": now.strftime(time_format), "size_tracker": len(track), diff --git a/aprsd/listen.py b/aprsd/listen.py new file mode 100644 index 0000000..9d0ced7 --- /dev/null +++ b/aprsd/listen.py @@ -0,0 +1,390 @@ +# +# Listen on amateur radio aprs-is network for messages and respond to them. +# You must have an amateur radio callsign to use this software. You must +# create an ~/.aprsd/config.yml file with all of the required settings. To +# generate an example config.yml, just run aprsd, then copy the sample config +# to ~/.aprsd/config.yml and edit the settings. +# +# APRS messages: +# l(ocation) = descriptive location of calling station +# w(eather) = temp, (hi/low) forecast, later forecast +# t(ime) = respond with the current time +# f(ortune) = respond with a short fortune +# -email_addr email text = send an email +# -2 = display the last 2 emails received +# p(ing) = respond with Pong!/time +# anything else = respond with usage +# +# (C)2018 Craig Lamparter +# License GPLv2 +# + +# python included libs +import datetime +import logging +from logging import NullHandler +from logging.handlers import RotatingFileHandler +import os +import signal +import sys +import time + +# local imports here +import aprsd +from aprsd import client, messaging, stats, threads, trace, utils +import aprslib +from aprslib.exceptions import LoginError +import click +import click_completion + +# setup the global logger +# logging.basicConfig(level=logging.DEBUG) # level=10 +LOG = logging.getLogger("APRSD") + + +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + +flask_enabled = False + +# server_event = threading.Event() + +# localization, please edit: +# HOST = "noam.aprs2.net" # north america tier2 servers round robin +# USER = "KM6XXX-9" # callsign of this aprs client with SSID +# PASS = "99999" # google how to generate this +# BASECALLSIGN = "KM6XXX" # callsign of radio in the field to send email +# shortcuts = { +# "aa" : "5551239999@vtext.com", +# "cl" : "craiglamparter@somedomain.org", +# "wb" : "5553909472@vtext.com" +# } + + +def custom_startswith(string, incomplete): + """A custom completion match that supports case insensitive matching.""" + if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"): + string = string.lower() + incomplete = incomplete.lower() + return string.startswith(incomplete) + + +click_completion.core.startswith = custom_startswith +click_completion.init() + + +cmd_help = """Shell completion for click-completion-command +Available shell types: +\b + %s +Default type: auto +""" % "\n ".join( + "{:<12} {}".format(k, click_completion.core.shells[k]) + for k in sorted(click_completion.core.shells.keys()) +) + + +@click.group(help=cmd_help, context_settings=CONTEXT_SETTINGS) +@click.version_option() +def main(): + pass + + +@main.command() +@click.option( + "-i", + "--case-insensitive/--no-case-insensitive", + help="Case insensitive completion", +) +@click.argument( + "shell", + required=False, + type=click_completion.DocumentedChoice(click_completion.core.shells), +) +def show(shell, case_insensitive): + """Show the click-completion-command completion code""" + extra_env = ( + {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} + if case_insensitive + else {} + ) + click.echo(click_completion.core.get_code(shell, extra_env=extra_env)) + + +@main.command() +@click.option( + "--append/--overwrite", + help="Append the completion code to the file", + default=None, +) +@click.option( + "-i", + "--case-insensitive/--no-case-insensitive", + help="Case insensitive completion", +) +@click.argument( + "shell", + required=False, + type=click_completion.DocumentedChoice(click_completion.core.shells), +) +@click.argument("path", required=False) +def install(append, case_insensitive, shell, path): + """Install the click-completion-command completion""" + extra_env = ( + {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} + if case_insensitive + else {} + ) + shell, path = click_completion.core.install( + shell=shell, + path=path, + append=append, + extra_env=extra_env, + ) + click.echo("{} completion installed in {}".format(shell, path)) + + +def signal_handler(sig, frame): + global flask_enabled + + threads.APRSDThreadList().stop_all() + if "subprocess" not in str(frame): + LOG.info( + "Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}".format( + datetime.datetime.now(), + ), + ) + time.sleep(5) + tracker = messaging.MsgTrack() + tracker.save() + LOG.info(stats.APRSDStats()) + # signal.signal(signal.SIGTERM, sys.exit(0)) + # sys.exit(0) + if flask_enabled: + signal.signal(signal.SIGTERM, sys.exit(0)) + + +# Setup the logging faciility +# to disable logging to stdout, but still log to file +# use the --quiet option on the cmdln +def setup_logging(config, loglevel, quiet): + log_level = utils.LOG_LEVELS[loglevel] + LOG.setLevel(log_level) + log_format = config["aprsd"].get("logformat", utils.DEFAULT_LOG_FORMAT) + date_format = config["aprsd"].get("dateformat", utils.DEFAULT_DATE_FORMAT) + log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) + log_file = config["aprsd"].get("logfile", None) + if log_file: + fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4) + else: + fh = NullHandler() + + fh.setFormatter(log_formatter) + LOG.addHandler(fh) + + imap_logger = None + if config["aprsd"]["email"].get("enabled", False) and config["aprsd"]["email"][ + "imap" + ].get("debug", False): + + imap_logger = logging.getLogger("imapclient.imaplib") + imap_logger.setLevel(log_level) + imap_logger.addHandler(fh) + + if not quiet: + sh = logging.StreamHandler(sys.stdout) + sh.setFormatter(log_formatter) + LOG.addHandler(sh) + if imap_logger: + imap_logger.addHandler(sh) + + +@main.command() +@click.option( + "--loglevel", + default="DEBUG", + show_default=True, + type=click.Choice( + ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], + case_sensitive=False, + ), + show_choices=True, + help="The log level to use for aprsd.log", +) +@click.option("--quiet", is_flag=True, default=False, help="Don't log to stdout") +@click.option( + "-c", + "--config", + "config_file", + show_default=True, + default=utils.DEFAULT_CONFIG_FILE, + help="The aprsd config file to use for options.", +) +@click.option( + "--aprs-login", + envvar="APRS_LOGIN", + show_envvar=True, + help="What callsign to send the message from.", +) +@click.option( + "--aprs-password", + envvar="APRS_PASSWORD", + show_envvar=True, + help="the APRS-IS password for APRS_LOGIN", +) +@click.option( + "--no-ack", + "-n", + is_flag=True, + show_default=True, + default=False, + help="Don't wait for an ack, just sent it to APRS-IS and bail.", +) +@click.option("--raw", default=None, help="Send a raw message. Implies --no-ack") +@click.argument("tocallsign", required=False) +@click.argument("command", nargs=-1, required=False) +def listen( + loglevel, + quiet, + config_file, + aprs_login, + aprs_password, + no_ack, + raw, + tocallsign, + command, +): + """Send a message to a callsign via APRS_IS.""" + global got_ack, got_response + + config = utils.parse_config(config_file) + if not aprs_login: + click.echo("Must set --aprs_login or APRS_LOGIN") + return + + if not aprs_password: + click.echo("Must set --aprs-password or APRS_PASSWORD") + return + + config["aprs"]["login"] = aprs_login + config["aprs"]["password"] = aprs_password + messaging.CONFIG = config + + setup_logging(config, loglevel, quiet) + LOG.info("APRSD TEST Started version: {}".format(aprsd.__version__)) + if type(command) is tuple: + command = " ".join(command) + if not quiet: + if raw: + LOG.info("L'{}' R'{}'".format(aprs_login, raw)) + else: + LOG.info("L'{}' To'{}' C'{}'".format(aprs_login, tocallsign, command)) + + flat_config = utils.flatten_dict(config) + LOG.info("Using CONFIG values:") + for x in flat_config: + if "password" in x or "aprsd.web.users.admin" in x: + LOG.info("{} = XXXXXXXXXXXXXXXXXXX".format(x)) + else: + LOG.info("{} = {}".format(x, flat_config[x])) + + got_ack = False + got_response = False + + # TODO(walt) - manually edit this list + # prior to running aprsd-listen listen + watch_list = [] + + # build last seen list + last_seen = {} + for callsign in watch_list: + call = callsign.replace("*", "") + last_seen[call] = datetime.datetime.now() + + LOG.debug("Last seen list") + LOG.debug(last_seen) + + @trace.trace + def rx_packet(packet): + global got_ack, got_response + LOG.debug("Got packet back {}".format(packet["raw"])) + + if packet["from"] in last_seen: + now = datetime.datetime.now() + age = str(now - last_seen[packet["from"]]) + + delta = utils.parse_delta_str(age) + d = datetime.timedelta(**delta) + + max_timeout = { + "seconds": config["aprsd"]["watch_list"]["alert_time_seconds"], + } + max_delta = datetime.timedelta(**max_timeout) + if d > max_delta: + LOG.debug( + "NOTIFY!!! {} last seen {} max age={}".format( + packet["from"], + age, + max_delta, + ), + ) + else: + LOG.debug("Not old enough to notify {} < {}".format(d, max_delta)) + LOG.debug("Update last seen from {}".format(packet["from"])) + last_seen[packet["from"]] = now + else: + LOG.debug( + "ignoring packet because {} not in watch list".format(packet["from"]), + ) + + resp = packet.get("response", None) + if resp == "ack": + ack_num = packet.get("msgNo") + LOG.info("We saw an ACK {} Ignoring".format(ack_num)) + # messaging.log_packet(packet) + got_ack = True + else: + message = packet.get("message_text", None) + fromcall = packet["from"] + msg_number = packet.get("msgNo", "0") + messaging.log_message( + "Received Message", + packet["raw"], + message, + fromcall=fromcall, + ack=msg_number, + ) + + try: + cl = client.Client(config) + cl.setup_connection() + except LoginError: + sys.exit(-1) + + aprs_client = client.get_client() + + # filter_str = 'b/{}'.format('/'.join(watch_list)) + # LOG.debug("Filter by '{}'".format(filter_str)) + # aprs_client.set_filter(filter_str) + filter_str = "p/{}".format("/".join(watch_list)) + LOG.debug("Filter by '{}'".format(filter_str)) + aprs_client.set_filter(filter_str) + + while True: + try: + # This will register a packet consumer with aprslib + # When new packets come in the consumer will process + # the packet + aprs_client.consumer(rx_packet, raw=False) + except aprslib.exceptions.ConnectionDrop: + LOG.error("Connection dropped, reconnecting") + time.sleep(5) + # Force the deletion of the client object connected to aprs + # This will cause a reconnect, next time client.get_client() + # is called + cl.reset() + except aprslib.exceptions.UnknownFormat: + LOG.error("Got a shitty packet") + + +if __name__ == "__main__": + main() diff --git a/aprsd/main.py b/aprsd/main.py index a28c7bd..4e144b8 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -504,12 +504,26 @@ def server( LOG.debug("Loading saved MsgTrack object.") messaging.MsgTrack().load() + rx_notify_queue = queue.Queue(maxsize=20) rx_msg_queue = queue.Queue(maxsize=20) tx_msg_queue = queue.Queue(maxsize=20) - msg_queues = {"rx": rx_msg_queue, "tx": tx_msg_queue} + msg_queues = { + "rx": rx_msg_queue, + "tx": tx_msg_queue, + "notify": rx_notify_queue, + } rx_thread = threads.APRSDRXThread(msg_queues=msg_queues, config=config) tx_thread = threads.APRSDTXThread(msg_queues=msg_queues, config=config) + if "watch_list" in config["aprsd"] and config["aprsd"]["watch_list"].get( + "enabled", + True, + ): + notify_thread = threads.APRSDNotifyThread( + msg_queues=msg_queues, + config=config, + ) + notify_thread.start() if email_enabled: email_thread = email.APRSDEmailThread(msg_queues=msg_queues, config=config) diff --git a/aprsd/messaging.py b/aprsd/messaging.py index a539094..33595e7 100644 --- a/aprsd/messaging.py +++ b/aprsd/messaging.py @@ -186,11 +186,12 @@ class MessageCounter: _instance = None max_count = 9999 + lock = None def __new__(cls, *args, **kwargs): """Make this a singleton class.""" if cls._instance is None: - cls._instance = super().__new__(cls) + cls._instance = super().__new__(cls, *args, **kwargs) cls._instance.val = RawValue("i", 1) cls._instance.lock = threading.Lock() return cls._instance diff --git a/aprsd/packets.py b/aprsd/packets.py index 2c08dbb..2565236 100644 --- a/aprsd/packets.py +++ b/aprsd/packets.py @@ -4,6 +4,10 @@ import time LOG = logging.getLogger("APRSD") +PACKET_TYPE_MESSAGE = "message" +PACKET_TYPE_ACK = "ack" +PACKET_TYPE_MICE = "mic-e" + class PacketList: """Class to track all of the packets rx'd and tx'd by aprsd.""" @@ -28,3 +32,17 @@ class PacketList: now = time.time() ts = str(now).split(".")[0] self.packet_list[ts] = packet + + +def get_packet_type(packet): + """Decode the packet type from the packet.""" + + msg_format = packet.get("format", None) + msg_response = packet.get("response", None) + if msg_format == "message": + packet_type = PACKET_TYPE_MESSAGE + elif msg_response == "ack": + packet_type = PACKET_TYPE_ACK + elif msg_format == "mic-e": + packet_type = PACKET_TYPE_MICE + return packet_type diff --git a/aprsd/plugin.py b/aprsd/plugin.py index 686f633..16ced7f 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -17,7 +17,7 @@ LOG = logging.getLogger("APRSD") hookspec = pluggy.HookspecMarker("aprsd") hookimpl = pluggy.HookimplMarker("aprsd") -CORE_PLUGINS = [ +CORE_MESSAGE_PLUGINS = [ "aprsd.plugins.email.EmailPlugin", "aprsd.plugins.fortune.FortunePlugin", "aprsd.plugins.location.LocationPlugin", @@ -29,17 +29,58 @@ CORE_PLUGINS = [ "aprsd.plugins.version.VersionPlugin", ] +CORE_NOTIFY_PLUGINS = [ + "aprsd.plugins.notify.BaseNotifyPlugin", +] + class APRSDCommandSpec: """A hook specification namespace.""" @hookspec - def run(self, fromcall, message, ack): + def run(self, packet): """My special little hook that you can customize.""" pass -class APRSDPluginBase(metaclass=abc.ABCMeta): +class APRSDNotificationPluginBase(metaclass=abc.ABCMeta): + """Base plugin class for all notification ased plugins. + + All these plugins will get every packet seen by APRSD's + registered list of HAM callsigns in the config file's + watch_list. + + When you want to 'notify' something when a packet is seen + by a particular HAM callsign, write a plugin based off of + this class. + """ + + def __init__(self, config): + """The aprsd config object is stored.""" + self.config = config + self.message_counter = 0 + + @hookimpl + def run(self, packet): + return self.notify(packet) + + @abc.abstractmethod + def notify(self, packet): + """This is the main method called when a packet is rx. + + This will get called when a packet is seen by a callsign + registered in the watch list in the config file.""" + pass + + +class APRSDMessagePluginBase(metaclass=abc.ABCMeta): + """Base Message plugin class. + + When you want to search for a particular command in an + APRSD message and send a direct reply, write a plugin + based off of this class. + """ + def __init__(self, config): """The aprsd config object is stored.""" self.config = config @@ -65,13 +106,14 @@ class APRSDPluginBase(metaclass=abc.ABCMeta): return self.message_counter @hookimpl - def run(self, fromcall, message, ack): + def run(self, packet): + message = packet.get("message_text", None) if re.search(self.command_regex, message): self.message_counter += 1 - return self.command(fromcall, message, ack) + return self.command(packet) @abc.abstractmethod - def command(self, fromcall, message, ack): + def command(self, packet): """This is the command that runs when the regex matches. To reply with a message over the air, return a string @@ -84,8 +126,11 @@ class PluginManager: # The singleton instance object for this class _instance = None - # the pluggy PluginManager - _pluggy_pm = None + # the pluggy PluginManager for all Message plugins + _pluggy_msg_pm = None + + # the pluggy PluginManager for all Notification plugins + _pluggy_notify_pm = None # aprsd config dict config = None @@ -130,7 +175,10 @@ class PluginManager: def is_plugin(self, obj): for c in inspect.getmro(obj): - if issubclass(c, APRSDPluginBase): + if issubclass(c, APRSDMessagePluginBase) or issubclass( + c, + APRSDNotificationPluginBase, + ): return True return False @@ -167,7 +215,7 @@ class PluginManager: obj = cls(**kwargs) return obj - def _load_plugin(self, plugin_name): + def _load_msg_plugin(self, plugin_name): """ Given a python fully qualified class path.name, Try importing the path, then creating the object, @@ -177,42 +225,81 @@ class PluginManager: try: plugin_obj = self._create_class( plugin_name, - APRSDPluginBase, + APRSDMessagePluginBase, config=self.config, ) if plugin_obj: LOG.info( - "Registering Command plugin '{}'({}) '{}'".format( + "Registering Message plugin '{}'({}) '{}'".format( plugin_name, plugin_obj.version, plugin_obj.command_regex, ), ) - self._pluggy_pm.register(plugin_obj) + self._pluggy_msg_pm.register(plugin_obj) + except Exception as ex: + LOG.exception("Couldn't load plugin '{}'".format(plugin_name), ex) + + def _load_notify_plugin(self, plugin_name): + """ + Given a python fully qualified class path.name, + Try importing the path, then creating the object, + then registering it as a aprsd Command Plugin + """ + plugin_obj = None + try: + plugin_obj = self._create_class( + plugin_name, + APRSDNotificationPluginBase, + config=self.config, + ) + if plugin_obj: + LOG.info( + "Registering Notification plugin '{}'({})".format( + plugin_name, + plugin_obj.version, + ), + ) + self._pluggy_notify_pm.register(plugin_obj) except Exception as ex: LOG.exception("Couldn't load plugin '{}'".format(plugin_name), ex) def reload_plugins(self): with self.lock: - del self._pluggy_pm + del self._pluggy_msg_pm + del self._pluggy_notify_pm self.setup_plugins() def setup_plugins(self): """Create the plugin manager and register plugins.""" - LOG.info("Loading Core APRSD Command Plugins") - enabled_plugins = self.config["aprsd"].get("enabled_plugins", None) - self._pluggy_pm = pluggy.PluginManager("aprsd") - self._pluggy_pm.add_hookspecs(APRSDCommandSpec) - if enabled_plugins: - for p_name in enabled_plugins: - self._load_plugin(p_name) + LOG.info("Loading APRSD Message Plugins") + enabled_msg_plugins = self.config["aprsd"].get("enabled_plugins", None) + self._pluggy_msg_pm = pluggy.PluginManager("aprsd") + self._pluggy_msg_pm.add_hookspecs(APRSDCommandSpec) + if enabled_msg_plugins: + for p_name in enabled_msg_plugins: + self._load_msg_plugin(p_name) else: # Enabled plugins isn't set, so we default to loading all of # the core plugins. - for p_name in CORE_PLUGINS: + for p_name in CORE_MESSAGE_PLUGINS: self._load_plugin(p_name) + if self.config["aprsd"]["watch_list"].get("enabled", False): + LOG.info("Loading APRSD Notification Plugins") + enabled_notify_plugins = self.config["aprsd"]["watch_list"].get( + "enabled_plugins", + None, + ) + self._pluggy_notify_pm = pluggy.PluginManager("aprsd") + self._pluggy_notify_pm.add_hookspecs(APRSDCommandSpec) + if enabled_notify_plugins: + for p_name in enabled_notify_plugins: + self._load_notify_plugin(p_name) + + # FIXME(Walt) - no real need to support loading random python classes + # from a directory anymore. Need to remove this. plugin_dir = self.config["aprsd"].get("plugin_dir", None) if plugin_dir: LOG.info("Trying to load custom plugins from '{}'".format(plugin_dir)) @@ -221,8 +308,6 @@ class PluginManager: LOG.info("Discovered {} modules to load".format(len(plugins_list))) for o in plugins_list: plugin_obj = None - # not setting enabled plugins means load all? - plugin_obj = o["obj"] if plugin_obj: LOG.info( @@ -238,14 +323,19 @@ class PluginManager: LOG.info("Skipping Custom Plugins directory.") LOG.info("Completed Plugin Loading.") - def run(self, *args, **kwargs): + def run(self, packet): """Execute all the pluguns run method.""" with self.lock: - return self._pluggy_pm.hook.run(*args, **kwargs) + return self._pluggy_msg_pm.hook.run(packet=packet) - def register(self, obj): + def notify(self, packet): + """Execute all the notify pluguns run method.""" + with self.lock: + return self._pluggy_notify_pm.hook.run(packet=packet) + + def register_msg(self, obj): """Register the plugin.""" - self._pluggy_pm.register(obj) + self._pluggy_msg_pm.register(obj) - def get_plugins(self): - return self._pluggy_pm.get_plugins() + def get_msg_plugins(self): + return self._pluggy_msg_pm.get_plugins() diff --git a/aprsd/plugins/email.py b/aprsd/plugins/email.py index 677d106..ead42e5 100644 --- a/aprsd/plugins/email.py +++ b/aprsd/plugins/email.py @@ -7,7 +7,7 @@ from aprsd import email, messaging, plugin, trace LOG = logging.getLogger("APRSD") -class EmailPlugin(plugin.APRSDPluginBase): +class EmailPlugin(plugin.APRSDMessagePluginBase): """Email Plugin.""" version = "1.0" @@ -19,8 +19,13 @@ class EmailPlugin(plugin.APRSDPluginBase): email_sent_dict = {} @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): LOG.info("Email COMMAND") + + fromcall = packet.get("from") + message = packet.get("message_text", None) + ack = packet.get("msgNo", "0") + reply = None if not self.config["aprsd"]["email"].get("enabled", False): LOG.debug("Email is not enabled in config file ignoring.") diff --git a/aprsd/plugins/fortune.py b/aprsd/plugins/fortune.py index 9764234..38bc415 100644 --- a/aprsd/plugins/fortune.py +++ b/aprsd/plugins/fortune.py @@ -7,7 +7,7 @@ from aprsd import plugin, trace LOG = logging.getLogger("APRSD") -class FortunePlugin(plugin.APRSDPluginBase): +class FortunePlugin(plugin.APRSDMessagePluginBase): """Fortune.""" version = "1.0" @@ -15,8 +15,13 @@ class FortunePlugin(plugin.APRSDPluginBase): command_name = "fortune" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): LOG.info("FortunePlugin") + + # fromcall = packet.get("from") + # message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") + reply = None fortune_path = shutil.which("fortune") diff --git a/aprsd/plugins/location.py b/aprsd/plugins/location.py index 2dec600..d6eb5a7 100644 --- a/aprsd/plugins/location.py +++ b/aprsd/plugins/location.py @@ -7,7 +7,7 @@ from aprsd import plugin, plugin_utils, trace, utils LOG = logging.getLogger("APRSD") -class LocationPlugin(plugin.APRSDPluginBase): +class LocationPlugin(plugin.APRSDMessagePluginBase): """Location!""" version = "1.0" @@ -15,8 +15,12 @@ class LocationPlugin(plugin.APRSDPluginBase): command_name = "location" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): LOG.info("Location Plugin") + fromcall = packet.get("from") + message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") + # get last location of a callsign, get descriptive name from weather service try: utils.check_config_option(self.config, ["services", "aprs.fi", "apiKey"]) diff --git a/aprsd/plugins/notify.py b/aprsd/plugins/notify.py new file mode 100644 index 0000000..eaca7bb --- /dev/null +++ b/aprsd/plugins/notify.py @@ -0,0 +1,23 @@ +import logging + +from aprsd import packets, plugin, trace + +LOG = logging.getLogger("APRSD") + + +class BaseNotifyPlugin(plugin.APRSDNotificationPluginBase): + """Notification base plugin.""" + + version = "1.0" + + @trace.trace + def notify(self, packet): + LOG.info("BaseNotifyPlugin") + + notify_callsign = self.config["aprsd"]["watch_list"]["alert_callsign"] + fromcall = packet.get("from") + + packet_type = packets.get_packet_type(packet) + # we shouldn't notify the alert user that they are online. + if fromcall != notify_callsign: + return "{} was just seen by type:'{}'".format(fromcall, packet_type) diff --git a/aprsd/plugins/ping.py b/aprsd/plugins/ping.py index 19e6ca2..26e3cef 100644 --- a/aprsd/plugins/ping.py +++ b/aprsd/plugins/ping.py @@ -6,7 +6,7 @@ from aprsd import plugin, trace LOG = logging.getLogger("APRSD") -class PingPlugin(plugin.APRSDPluginBase): +class PingPlugin(plugin.APRSDMessagePluginBase): """Ping.""" version = "1.0" @@ -14,8 +14,11 @@ class PingPlugin(plugin.APRSDPluginBase): command_name = "ping" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): LOG.info("PINGPlugin") + # fromcall = packet.get("from") + # message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") stm = time.localtime() h = stm.tm_hour m = stm.tm_min diff --git a/aprsd/plugins/query.py b/aprsd/plugins/query.py index 35b2404..780bc89 100644 --- a/aprsd/plugins/query.py +++ b/aprsd/plugins/query.py @@ -7,7 +7,7 @@ from aprsd import messaging, plugin, trace LOG = logging.getLogger("APRSD") -class QueryPlugin(plugin.APRSDPluginBase): +class QueryPlugin(plugin.APRSDMessagePluginBase): """Query command.""" version = "1.0" @@ -15,9 +15,13 @@ class QueryPlugin(plugin.APRSDPluginBase): command_name = "query" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): LOG.info("Query COMMAND") + fromcall = packet.get("from") + message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") + tracker = messaging.MsgTrack() now = datetime.datetime.now() reply = "Pending messages ({}) {}".format( diff --git a/aprsd/plugins/stock.py b/aprsd/plugins/stock.py index 4d3b20f..87c025e 100644 --- a/aprsd/plugins/stock.py +++ b/aprsd/plugins/stock.py @@ -7,7 +7,7 @@ import yfinance as yf LOG = logging.getLogger("APRSD") -class StockPlugin(plugin.APRSDPluginBase): +class StockPlugin(plugin.APRSDMessagePluginBase): """Stock market plugin for fetching stock quotes""" version = "1.0" @@ -15,9 +15,13 @@ class StockPlugin(plugin.APRSDPluginBase): command_name = "stock" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): LOG.info("StockPlugin") + # fromcall = packet.get("from") + message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") + a = re.search(r"^.*\s+(.*)", message) if a is not None: searchcall = a.group(1) diff --git a/aprsd/plugins/time.py b/aprsd/plugins/time.py index e5bc3ef..caf4add 100644 --- a/aprsd/plugins/time.py +++ b/aprsd/plugins/time.py @@ -8,7 +8,7 @@ import pytz LOG = logging.getLogger("APRSD") -class TimePlugin(plugin.APRSDPluginBase): +class TimePlugin(plugin.APRSDMessagePluginBase): """Time command.""" version = "1.0" @@ -39,7 +39,7 @@ class TimePlugin(plugin.APRSDPluginBase): return reply @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): LOG.info("TIME COMMAND") # So we can mock this in unit tests localzone = self._get_local_tz() @@ -54,7 +54,11 @@ class TimeOpenCageDataPlugin(TimePlugin): command_name = "Time" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): + fromcall = packet.get("from") + # message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") + api_key = self.config["services"]["aprs.fi"]["apiKey"] try: aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall) @@ -95,7 +99,10 @@ class TimeOWMPlugin(TimePlugin): command_name = "Time" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): + fromcall = packet.get("from") + # message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") api_key = self.config["services"]["aprs.fi"]["apiKey"] try: aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall) diff --git a/aprsd/plugins/version.py b/aprsd/plugins/version.py index cc0e078..2619e49 100644 --- a/aprsd/plugins/version.py +++ b/aprsd/plugins/version.py @@ -6,7 +6,7 @@ from aprsd import plugin, stats, trace LOG = logging.getLogger("APRSD") -class VersionPlugin(plugin.APRSDPluginBase): +class VersionPlugin(plugin.APRSDMessagePluginBase): """Version of APRSD Plugin.""" version = "1.0" @@ -18,8 +18,11 @@ class VersionPlugin(plugin.APRSDPluginBase): email_sent_dict = {} @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): LOG.info("Version COMMAND") + # fromcall = packet.get("from") + # message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") stats_obj = stats.APRSDStats() s = stats_obj.stats() return "APRSD ver:{} uptime:{}".format( diff --git a/aprsd/plugins/weather.py b/aprsd/plugins/weather.py index cd0a741..9739708 100644 --- a/aprsd/plugins/weather.py +++ b/aprsd/plugins/weather.py @@ -8,7 +8,7 @@ import requests LOG = logging.getLogger("APRSD") -class USWeatherPlugin(plugin.APRSDPluginBase): +class USWeatherPlugin(plugin.APRSDMessagePluginBase): """USWeather Command Returns a weather report for the calling weather station @@ -26,8 +26,11 @@ class USWeatherPlugin(plugin.APRSDPluginBase): command_name = "weather" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): LOG.info("Weather Plugin") + fromcall = packet.get("from") + # message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") try: utils.check_config_option(self.config, ["services", "aprs.fi", "apiKey"]) except Exception as ex: @@ -66,7 +69,7 @@ class USWeatherPlugin(plugin.APRSDPluginBase): return reply -class USMetarPlugin(plugin.APRSDPluginBase): +class USMetarPlugin(plugin.APRSDMessagePluginBase): """METAR Command This provides a METAR weather report from a station near the caller @@ -86,7 +89,10 @@ class USMetarPlugin(plugin.APRSDPluginBase): command_name = "Metar" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): + fromcall = packet.get("from") + message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") LOG.info("WX Plugin '{}'".format(message)) a = re.search(r"^.*\s+(.*)", message) if a is not None: @@ -154,7 +160,7 @@ class USMetarPlugin(plugin.APRSDPluginBase): return reply -class OWMWeatherPlugin(plugin.APRSDPluginBase): +class OWMWeatherPlugin(plugin.APRSDMessagePluginBase): """OpenWeatherMap Weather Command This provides weather near the caller or callsign. @@ -178,7 +184,10 @@ class OWMWeatherPlugin(plugin.APRSDPluginBase): command_name = "Weather" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): + fromcall = packet.get("from") + message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") LOG.info("OWMWeather Plugin '{}'".format(message)) a = re.search(r"^.*\s+(.*)", message) if a is not None: @@ -271,7 +280,7 @@ class OWMWeatherPlugin(plugin.APRSDPluginBase): return reply -class AVWXWeatherPlugin(plugin.APRSDPluginBase): +class AVWXWeatherPlugin(plugin.APRSDMessagePluginBase): """AVWXWeatherMap Weather Command Fetches a METAR weather report for the nearest @@ -299,7 +308,10 @@ class AVWXWeatherPlugin(plugin.APRSDPluginBase): command_name = "Weather" @trace.trace - def command(self, fromcall, message, ack): + def command(self, packet): + fromcall = packet.get("from") + message = packet.get("message_text", None) + # ack = packet.get("msgNo", "0") LOG.info("OWMWeather Plugin '{}'".format(message)) a = re.search(r"^.*\s+(.*)", message) if a is not None: diff --git a/aprsd/stats.py b/aprsd/stats.py index dbe141c..235ad6e 100644 --- a/aprsd/stats.py +++ b/aprsd/stats.py @@ -33,6 +33,7 @@ class APRSDStats: _mem_current = 0 _mem_peak = 0 + _watch_list = {} def __new__(cls, *args, **kwargs): if cls._instance is None: @@ -169,6 +170,15 @@ class APRSDStats: with self.lock: self._email_thread_last_time = datetime.datetime.now() + @property + def watch_list(self): + with self.lock: + return self._watch_list + + def update_watch_list(self, watch_list): + with self.lock: + self._watch_list = watch_list + def stats(self): now = datetime.datetime.now() if self._email_thread_last_time: @@ -182,7 +192,7 @@ class APRSDStats: last_aprsis_keepalive = "never" pm = plugin.PluginManager() - plugins = pm.get_plugins() + plugins = pm.get_msg_plugins() plugin_stats = {} def full_name_with_qualname(obj): @@ -202,6 +212,7 @@ class APRSDStats: "memory_current_str": utils.human_size(self.memory), "memory_peak": self.memory_peak, "memory_peak_str": utils.human_size(self.memory_peak), + "watch_list": self.watch_list, }, "aprs-is": { "server": self.aprsis_server, diff --git a/aprsd/threads.py b/aprsd/threads.py index cea9deb..16b6976 100644 --- a/aprsd/threads.py +++ b/aprsd/threads.py @@ -6,7 +6,7 @@ import threading import time import tracemalloc -from aprsd import client, messaging, packets, plugin, stats, trace, utils +from aprsd import client, messaging, packets, plugin, stats, utils import aprslib LOG = logging.getLogger("APRSD") @@ -114,6 +114,96 @@ class KeepAliveThread(APRSDThread): return True +class APRSDNotifyThread(APRSDThread): + last_seen = {} + + def __init__(self, msg_queues, config): + super().__init__("NOTIFY_MSG") + self.msg_queues = msg_queues + self.config = config + for callsign in config["aprsd"]["watch_list"].get("callsigns", []): + call = callsign.replace("*", "") + # FIXME(waboring) - we should fetch the last time we saw + # a beacon from a callsign or some other mechanism to find + # last time a message was seen by aprs-is. For now this + # is all we can do. + self.last_seen[call] = datetime.datetime.now() + self.update_stats() + + def update_stats(self): + stats_seen = {} + for callsign in self.last_seen: + stats_seen[callsign] = str(self.last_seen[callsign]) + + stats.APRSDStats().update_watch_list(stats_seen) + + def loop(self): + try: + packet = self.msg_queues["notify"].get(timeout=5) + + if packet["from"] in self.last_seen: + # We only notify if the last time a callsign was seen + # is older than the alert_time_seconds + now = datetime.datetime.now() + age = str(now - self.last_seen[packet["from"]]) + + delta = utils.parse_delta_str(age) + d = datetime.timedelta(**delta) + + watch_list_conf = self.config["aprsd"]["watch_list"] + max_timeout = { + "seconds": watch_list_conf["alert_time_seconds"], + } + max_delta = datetime.timedelta(**max_timeout) + + if d > max_delta: + LOG.info( + "NOTIFY {} last seen {} max age={}".format( + packet["from"], + age, + max_delta, + ), + ) + # NOW WE RUN through the notify plugins. + # If they return a msg, then we queue it for sending. + pm = plugin.PluginManager() + results = pm.notify(packet) + for reply in results: + if reply is not messaging.NULL_MESSAGE: + LOG.debug("Sending '{}'".format(reply)) + + msg = messaging.TextMessage( + self.config["aprs"]["login"], + watch_list_conf["alert_callsign"], + reply, + ) + self.msg_queues["tx"].put(msg) + else: + LOG.debug("Got NULL MESSAGE from plugin") + + else: + LOG.debug( + "Not old enough to notify callsign {}: {} < {}".format( + packet["from"], + age, + max_delta, + ), + ) + + LOG.debug("Update last seen from {}".format(packet["from"])) + self.last_seen[packet["from"]] = now + else: + LOG.debug("Ignoring packet from {}".format(packet["from"])) + + # Allows stats object to have latest info from the last_seen dict + self.update_stats() + LOG.debug("Packet processing complete") + except queue.Empty: + pass + # Continue to loop + return True + + class APRSDRXThread(APRSDThread): def __init__(self, msg_queues, config): super().__init__("RX_MSG") @@ -127,6 +217,23 @@ class APRSDRXThread(APRSDThread): def loop(self): aprs_client = client.get_client() + # if we have a watch list enabled, we need to add filtering + # to enable seeing packets from the watch list. + if "watch_list" in self.config["aprsd"] and self.config["aprsd"][ + "watch_list" + ].get("enabled", False): + # watch list is enabled + watch_list = self.config["aprsd"]["watch_list"].get( + "callsigns", + [], + ) + # make sure the timeout is set or this doesn't work + if watch_list: + filter_str = "p/{}".format("/".join(watch_list)) + aprs_client.set_filter(filter_str) + else: + LOG.warning("Watch list enabled, but no callsigns set.") + # setup the consumer of messages and block until a messages try: # This will register a packet consumer with aprslib @@ -189,7 +296,7 @@ class APRSDRXThread(APRSDThread): # Get singleton of the PM pm = plugin.PluginManager() try: - results = pm.run(fromcall=fromcall, message=message, ack=msg_id) + results = pm.run(packet) for reply in results: found_command = True # A plugin can return a null message flag which signals @@ -208,7 +315,7 @@ class APRSDRXThread(APRSDThread): LOG.debug("Got NULL MESSAGE from plugin") if not found_command: - plugins = pm.get_plugins() + plugins = pm.get_msg_plugins() names = [x.command_name for x in plugins] names.sort() @@ -237,30 +344,43 @@ class APRSDRXThread(APRSDThread): self.msg_queues["tx"].put(ack) LOG.debug("Packet processing complete") - @trace.trace def process_packet(self, packet): """Process a packet recieved from aprs-is server.""" try: - stats.APRSDStats().msgs_rx_inc() - packets.PacketList().add(packet) + LOG.debug("Adding packet to notify queue {}".format(packet["raw"])) + self.msg_queues["notify"].put(packet) - msg = packet.get("message_text", None) - msg_format = packet.get("format", None) - msg_response = packet.get("response", None) - if msg_format == "message" and msg: - # we want to send the message through the - # plugins - self.process_message_packet(packet) - return - elif msg_response == "ack": - self.process_ack_packet(packet) - return + # since we can see packets from anyone now with the + # watch list, we need to filter messages directly only to us. + tocall = packet.get("addresse", None) + if tocall == self.config["aprs"]["login"]: + stats.APRSDStats().msgs_rx_inc() + packets.PacketList().add(packet) - if msg_format == "mic-e": - # process a mic-e packet - self.process_mic_e_packet(packet) - return + msg = packet.get("message_text", None) + msg_format = packet.get("format", None) + msg_response = packet.get("response", None) + if msg_format == "message" and msg: + # we want to send the message through the + # plugins + self.process_message_packet(packet) + return + elif msg_response == "ack": + self.process_ack_packet(packet) + return + + if msg_format == "mic-e": + # process a mic-e packet + self.process_mic_e_packet(packet) + return + else: + LOG.debug( + "Packet wasn't meant for us '{}'. Ignoring packet to '{}'".format( + self.config["aprs"]["login"], + tocall, + ), + ) except (aprslib.ParseError, aprslib.UnknownFormat) as exp: LOG.exception("Failed to parse packet from aprs-is", exp) @@ -274,7 +394,7 @@ class APRSDTXThread(APRSDThread): def loop(self): try: - msg = self.msg_queues["tx"].get(timeout=0.1) + msg = self.msg_queues["tx"].get(timeout=5) packets.PacketList().add(msg.dict()) msg.send() except queue.Empty: diff --git a/aprsd/utils.py b/aprsd/utils.py index 50117c2..172773f 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -6,6 +6,7 @@ import functools import logging import os from pathlib import Path +import re import sys import threading @@ -32,9 +33,9 @@ DEFAULT_DATE_FORMAT = "%m/%d/%Y %I:%M:%S %p" # an example of what should be in the ~/.aprsd/config.yml DEFAULT_CONFIG_DICT = { - "ham": {"callsign": "CALLSIGN"}, + "ham": {"callsign": "NOCALL"}, "aprs": { - "login": "CALLSIGN", + "login": "NOCALL", "password": "00000", "host": "rotate.aprs2.net", "port": 14580, @@ -45,15 +46,24 @@ DEFAULT_CONFIG_DICT = { "dateformat": DEFAULT_DATE_FORMAT, "trace": False, "plugin_dir": "~/.config/aprsd/plugins", - "enabled_plugins": plugin.CORE_PLUGINS, + "enabled_plugins": plugin.CORE_MESSAGE_PLUGINS, "units": "imperial", + "watch_list": { + "enabled": False, + # Who gets the alert? + "alert_callsign": "NOCALL", + # 43200 is 12 hours + "alert_time_seconds": 43200, + "callsigns": [], + "enabled_plugins": plugin.CORE_NOTIFY_PLUGINS, + }, "web": { "enabled": True, "logging_enabled": True, "host": "0.0.0.0", "port": 8001, "users": { - "admin": "aprsd", + "admin": "password-here", }, }, "email": { @@ -334,6 +344,13 @@ def parse_config(config_file): default_fail=DEFAULT_CONFIG_DICT["aprsd"]["web"]["users"]["admin"], ) + if config["aprsd"]["watch_list"]["enabled"] is True: + check_option( + config, + ["aprsd", "watch_list", "alert_callsign"], + default_fail=DEFAULT_CONFIG_DICT["aprsd"]["watch_list"]["alert_callsign"], + ) + if config["aprsd"]["email"]["enabled"] is True: # Check IMAP server settings check_option(config, ["aprsd", "email", "imap", "host"]) @@ -407,3 +424,14 @@ def flatten_dict(d, parent_key="", sep="."): else: items.append((new_key, v)) return dict(items) + + +def parse_delta_str(s): + if "day" in s: + m = re.match( + r"(?P[-\d]+) day[s]*, (?P\d+):(?P\d+):(?P\d[\.\d+]*)", + s, + ) + else: + m = re.match(r"(?P\d+):(?P\d+):(?P\d[\.\d+]*)", s) + return {key: float(val) for key, val in m.groupdict().items()} diff --git a/aprsd/web/static/js/charts.js b/aprsd/web/static/js/charts.js index 24285ff..0f79ab5 100644 --- a/aprsd/web/static/js/charts.js +++ b/aprsd/web/static/js/charts.js @@ -177,7 +177,7 @@ function updateQuadData(chart, label, first, second, third, fourth) { function update_stats( data ) { $("#version").text( data["stats"]["aprsd"]["version"] ); - $("#aprsis").text( "APRS-IS Server: " + data["stats"]["aprs-is"]["server"] ); + $("#aprsis").html( "APRS-IS Server: " + data["stats"]["aprs-is"]["server"] + "" ); $("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] ); const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json'); $("#jsonstats").html(html_pretty); @@ -185,6 +185,16 @@ function update_stats( data ) { updateQuadData(message_chart, short_time, data["stats"]["messages"]["sent"], data["stats"]["messages"]["recieved"], data["stats"]["messages"]["ack_sent"], data["stats"]["messages"]["ack_recieved"]); updateDualData(email_chart, short_time, data["stats"]["email"]["sent"], data["stats"]["email"]["recieved"]); updateDualData(memory_chart, short_time, data["stats"]["aprsd"]["memory_peak"], data["stats"]["aprsd"]["memory_current"]); + + // Update the watch list + var watchdiv = $("#watchDiv"); + var html_str = '' + watchdiv.html('') + jQuery.each(data["stats"]["aprsd"]["watch_list"], function(i, val) { + html_str += '' + }); + html_str += "
HAM CallsignAge since last seen by APRSD
' + i + '' + val + '
"; + watchdiv.append(html_str); } diff --git a/aprsd/web/templates/index.html b/aprsd/web/templates/index.html index b4246f9..d77d762 100644 --- a/aprsd/web/templates/index.html +++ b/aprsd/web/templates/index.html @@ -68,6 +68,7 @@ @@ -94,6 +95,15 @@ +
+

+ Callsign Watch List ({{ watch_count }}) +     + Notification age - {{ watch_age }} +

+
Loading
+
+

Config

{{ config_json|safe }}
diff --git a/setup.cfg b/setup.cfg index 986a186..bad2b0c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ packages = [entry_points] console_scripts = aprsd = aprsd.main:main + aprsd-listen = aprsd.listen:main aprsd-dev = aprsd.dev:main aprsd-healthcheck = aprsd.healthcheck:main fake_aprs = aprsd.fake_aprs:main diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 501ef8c..2acf70b 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -21,13 +21,23 @@ class TestPlugin(unittest.TestCase): # Inintialize the stats object with the config stats.APRSDStats(self.config) + def fake_packet(self, fromcall="KFART", message=None, msg_number=None): + packet = {"from": fromcall} + if message: + packet["message_text"] = message + + if msg_number: + packet["msgNo"] = msg_number + + return packet + @mock.patch("shutil.which") def test_fortune_fail(self, mock_which): fortune = fortune_plugin.FortunePlugin(self.config) mock_which.return_value = None - message = "fortune" expected = "Fortune command not installed" - actual = fortune.run(self.fromcall, message, self.ack) + packet = self.fake_packet(message="fortune") + actual = fortune.run(packet) self.assertEqual(expected, actual) @mock.patch("subprocess.check_output") @@ -38,18 +48,18 @@ class TestPlugin(unittest.TestCase): mock_output.return_value = "Funny fortune" - message = "fortune" expected = "Funny fortune" - actual = fortune.run(self.fromcall, message, self.ack) + packet = self.fake_packet(message="fortune") + actual = fortune.run(packet) self.assertEqual(expected, actual) @mock.patch("aprsd.messaging.MsgTrack.flush") def test_query_flush(self, mock_flush): - message = "!delete" + packet = self.fake_packet(message="!delete") query = query_plugin.QueryPlugin(self.config) expected = "Deleted ALL pending msgs." - actual = query.run(self.fromcall, message, self.ack) + actual = query.run(packet) mock_flush.assert_called_once() self.assertEqual(expected, actual) @@ -57,11 +67,11 @@ class TestPlugin(unittest.TestCase): def test_query_restart_delayed(self, mock_restart): track = messaging.MsgTrack() track.track = {} - message = "!4" + packet = self.fake_packet(message="!4") query = query_plugin.QueryPlugin(self.config) expected = "No pending msgs to resend" - actual = query.run(self.fromcall, message, self.ack) + actual = query.run(packet) mock_restart.assert_not_called() self.assertEqual(expected, actual) mock_restart.reset_mock() @@ -69,7 +79,7 @@ class TestPlugin(unittest.TestCase): # add a message msg = messaging.TextMessage(self.fromcall, "testing", self.ack) track.add(msg) - actual = query.run(self.fromcall, message, self.ack) + actual = query.run(packet) mock_restart.assert_called_once() @mock.patch("aprsd.plugins.time.TimePlugin._get_local_tz") @@ -89,22 +99,28 @@ class TestPlugin(unittest.TestCase): fake_time.tm_sec = 13 time = time_plugin.TimePlugin(self.config) - fromcall = "KFART" - message = "location" - ack = 1 + packet = self.fake_packet( + fromcall="KFART", + message="location", + msg_number=1, + ) - actual = time.run(fromcall, message, ack) + actual = time.run(packet) self.assertEqual(None, actual) cur_time = fuzzy(h, m, 1) - message = "time" + packet = self.fake_packet( + fromcall="KFART", + message="time", + msg_number=1, + ) local_short_str = local_t.strftime("%H:%M %Z") expected = "{} ({})".format( cur_time, local_short_str, ) - actual = time.run(fromcall, message, ack) + actual = time.run(packet) self.assertEqual(expected, actual) @mock.patch("time.localtime") @@ -117,11 +133,13 @@ class TestPlugin(unittest.TestCase): ping = ping_plugin.PingPlugin(self.config) - fromcall = "KFART" - message = "location" - ack = 1 + packet = self.fake_packet( + fromcall="KFART", + message="location", + msg_number=1, + ) - result = ping.run(fromcall, message, ack) + result = ping.run(packet) self.assertEqual(None, result) def ping_str(h, m, s): @@ -134,31 +152,49 @@ class TestPlugin(unittest.TestCase): + str(s).zfill(2) ) - message = "Ping" - actual = ping.run(fromcall, message, ack) + packet = self.fake_packet( + fromcall="KFART", + message="Ping", + msg_number=1, + ) + actual = ping.run(packet) expected = ping_str(h, m, s) self.assertEqual(expected, actual) - message = "ping" - actual = ping.run(fromcall, message, ack) + packet = self.fake_packet( + fromcall="KFART", + message="ping", + msg_number=1, + ) + actual = ping.run(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin.PluginManager.get_plugins") + @mock.patch("aprsd.plugin.PluginManager.get_msg_plugins") def test_version(self, mock_get_plugins): expected = "APRSD ver:{} uptime:0:0:0".format(aprsd.__version__) version = version_plugin.VersionPlugin(self.config) - fromcall = "KFART" - message = "No" - ack = 1 + packet = self.fake_packet( + fromcall="KFART", + message="No", + msg_number=1, + ) - actual = version.run(fromcall, message, ack) + actual = version.run(packet) self.assertEqual(None, actual) - message = "version" - actual = version.run(fromcall, message, ack) + packet = self.fake_packet( + fromcall="KFART", + message="version", + msg_number=1, + ) + actual = version.run(packet) self.assertEqual(expected, actual) - message = "Version" - actual = version.run(fromcall, message, ack) + packet = self.fake_packet( + fromcall="KFART", + message="Version", + msg_number=1, + ) + actual = version.run(packet) self.assertEqual(expected, actual)