From d09a66006b5886661e1b34aa641ca227fd418e9b Mon Sep 17 00:00:00 2001 From: Hemna Date: Sat, 12 Dec 2020 15:53:06 -0500 Subject: [PATCH] Created plugin.py for Command Plugins This patch adds the new APRSD Command Plugin architecture. All Comand plugins must implement the same object API, which includes plugin object is subclass of APRSDPluginBase version attribute command_regex attribute command method When an APRS command is detected, then the regex is run against the command. If the command_regex matches, then the plugin's command() method will be called. If the command() method returns a string, then that string is sent as a reply to the APRS caller. A new aprs.yml config section is added to support selecting which plugins to enable. If you want all plugins enabled, then omit "enabled_plugins" entirely from the aprs section of the config. To load custom plugins: 1) create a directory with an __init__.py file 2) Add a plugin.py file that contains your plugin Look at the exmaples directory for an example plugin. --- aprsd/main.py | 163 +------------- aprsd/plugin.py | 331 +++++++++++++++++++++++++++++ aprsd/utils.py | 6 + docker-compose.yml | 2 +- examples/plugins/__init__.py | 0 examples/plugins/example_plugin.py | 18 ++ requirements.txt | 1 + tox.ini | 2 +- 8 files changed, 368 insertions(+), 155 deletions(-) create mode 100644 aprsd/plugin.py create mode 100644 examples/plugins/__init__.py create mode 100644 examples/plugins/example_plugin.py diff --git a/aprsd/main.py b/aprsd/main.py index a452efa..f0220b4 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -24,7 +24,6 @@ import datetime import email import imaplib -import json import logging import os import pprint @@ -33,7 +32,6 @@ import select import signal import smtplib import socket -import subprocess import sys import threading import time @@ -43,14 +41,12 @@ from logging.handlers import RotatingFileHandler import click import click_completion import imapclient -import requests import six import yaml # local imports here import aprsd -from aprsd import utils -from aprsd.fuzzyclock import fuzzy +from aprsd import plugin, utils # setup the global logger LOG = logging.getLogger("APRSD") @@ -703,11 +699,6 @@ def sample_config(): COMMAND_ENVELOPE = { "email": {"command": "^-.*", "function": "command_email"}, - "fortune": {"command": "^[fF]", "function": "command_fortune"}, - "location": {"command": "^[lL]", "function": "command_location"}, - "weather": {"command": "^[wW]", "function": "command_weather"}, - "ping": {"command": "^[pP]", "function": "command_ping"}, - "time": {"command": "^[tT]", "function": "command_time"}, } @@ -768,149 +759,6 @@ def command_email(fromcall, message, ack): return (fromcall, message, ack) -def command_fortune(fromcall, message, ack): - try: - process = subprocess.Popen( - ["/usr/games/fortune", "-s", "-n 60"], stdout=subprocess.PIPE - ) - reply = process.communicate()[0] - # send_message(fromcall, reply.rstrip()) - reply = reply.decode(errors="ignore").rstrip() - - except Exception as ex: - reply = "Fortune command failed '{}'".format(ex) - LOG.error(reply) - - send_message(fromcall, reply) - return (fromcall, message, ack) - - -def command_location(fromcall, message, ack): - LOG.info("Location COMMAND") - # get last location of a callsign, get descriptive name from weather service - try: - a = re.search( - r"^.*\s+(.*)", message - ) # optional second argument is a callsign to search - if a is not None: - searchcall = a.group(1) - searchcall = searchcall.upper() - else: - searchcall = fromcall # if no second argument, search for calling station - url = ( - "http://api.aprs.fi/api/get?name=" - + searchcall - + "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json" - ) - response = requests.get(url) - # aprs_data = json.loads(response.read()) - aprs_data = json.loads(response.text) - lat = aprs_data["entries"][0]["lat"] - lon = aprs_data["entries"][0]["lng"] - try: # altitude not always provided - alt = aprs_data["entries"][0]["altitude"] - except Exception: - alt = 0 - altfeet = int(alt * 3.28084) - aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"] - aprs_lasttime_seconds = aprs_lasttime_seconds.encode( - "ascii", errors="ignore" - ) # unicode to ascii - delta_seconds = time.time() - int(aprs_lasttime_seconds) - delta_hours = delta_seconds / 60 / 60 - url2 = ( - "https://forecast.weather.gov/MapClick.php?lat=" - + str(lat) - + "&lon=" - + str(lon) - + "&FcstType=json" - ) - response2 = requests.get(url2) - wx_data = json.loads(response2.text) - - reply = "{}: {} {}' {},{} {}h ago".format( - searchcall, - wx_data["location"]["areaDescription"], - str(altfeet), - str(alt), - str(lon), - str("%.1f" % round(delta_hours, 1)), - ) - # reply = reply.encode('ascii', errors='ignore') # unicode to ascii - send_message(fromcall, reply.rstrip()) - except Exception as e: - LOG.debug("Locate failed with: " + "%s" % str(e)) - reply = "Unable to find station " + searchcall + ". Sending beacons?" - send_message(fromcall, reply.rstrip()) - - return (fromcall, message, ack) - - -def command_weather(fromcall, message, ack): - """Do weather command and send response.""" - LOG.info("WEATHER COMMAND") - try: - url = ( - "http://api.aprs.fi/api/get?" - "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json" - "&name=%s" % fromcall - ) - response = requests.get(url) - # aprs_data = json.loads(response.read()) - aprs_data = json.loads(response.text) - lat = aprs_data["entries"][0]["lat"] - lon = aprs_data["entries"][0]["lng"] - url2 = ( - "https://forecast.weather.gov/MapClick.php?lat=%s" - "&lon=%s&FcstType=json" % (lat, lon) - ) - response2 = requests.get(url2) - # wx_data = json.loads(response2.read()) - wx_data = json.loads(response2.text) - reply = "%sF(%sF/%sF) %s. %s, %s." % ( - wx_data["currentobservation"]["Temp"], - wx_data["data"]["temperature"][0], - wx_data["data"]["temperature"][1], - wx_data["data"]["weather"][0], - wx_data["time"]["startPeriodName"][1], - wx_data["data"]["weather"][1], - ) - LOG.debug("reply: " + reply.rstrip()) - send_message(fromcall, reply.rstrip()) - except Exception as e: - LOG.debug("Weather failed with: " + "%s" % str(e)) - reply = "Unable to find you (send beacon?)" - - return (fromcall, message, ack) - - -def command_ping(fromcall, message, ack): - LOG.info("PING COMMAND") - stm = time.localtime() - h = stm.tm_hour - m = stm.tm_min - s = stm.tm_sec - reply = "Pong! " + str(h).zfill(2) + ":" + str(m).zfill(2) + ":" + str(s).zfill(2) - send_message(fromcall, reply.rstrip()) - return (fromcall, message, ack) - - -def command_time(fromcall, message, ack): - LOG.info("TIME COMMAND") - stm = time.localtime() - h = stm.tm_hour - m = stm.tm_min - cur_time = fuzzy(h, m, 1) - reply = "{} ({}:{} PDT) ({})".format( - cur_time, str(h), str(m).rjust(2, "0"), message.rstrip() - ) - thread = threading.Thread( - target=send_message, name="send_message", args=(fromcall, reply) - ) - thread.start() - return (fromcall, message, ack) - - # main() ### @main.command() @click.option( @@ -964,6 +812,9 @@ def server(loglevel, quiet, config_file): read_sockets = [client_sock] + # Register plugins + pm = plugin.setup_plugins(CONFIG) + fromcall = message = ack = None while True: LOG.debug("Main loop start") @@ -1038,6 +889,12 @@ def server(loglevel, quiet, config_file): ack_dict.update({int(a.group(1)): 1}) continue # break out of this so we don't ack an ack at the end + # call our `myhook` hook + results = pm.hook.run(fromcall=fromcall, message=message, ack=ack) + LOG.info("PLUGINS returned {}".format(results)) + for reply in results: + send_message(fromcall, reply) + # it's not an ack, so try and process user input found_command = False for key in COMMAND_ENVELOPE: diff --git a/aprsd/plugin.py b/aprsd/plugin.py new file mode 100644 index 0000000..3303d8d --- /dev/null +++ b/aprsd/plugin.py @@ -0,0 +1,331 @@ +# The base plugin class +import abc +import fnmatch +import imp +import inspect +import json +import logging +import os +import re +import subprocess +import time + +import pluggy +import requests +import six + +from aprsd.fuzzyclock import fuzzy + +# setup the global logger +LOG = logging.getLogger("APRSD") + +hookspec = pluggy.HookspecMarker("aprsd") +hookimpl = pluggy.HookimplMarker("aprsd") + +CORE_PLUGINS = [ + "FortunePlugin", + "LocationPlugin", + "PingPlugin", + "TimePlugin", + "WeatherPlugin", +] + + +def setup_plugins(config): + """Create the plugin manager and register plugins.""" + + LOG.info("Loading Core APRSD Command Plugins") + enabled_plugins = config["aprsd"].get("enabled_plugins", None) + pm = pluggy.PluginManager("aprsd") + pm.add_hookspecs(APRSDCommandSpec) + for p_name in CORE_PLUGINS: + plugin_obj = None + if enabled_plugins: + if p_name in enabled_plugins: + plugin_obj = globals()[p_name]() + else: + # Enabled plugins isn't set, so we default to loading all of + # the core plugins. + plugin_obj = globals()[p_name]() + + if plugin_obj: + LOG.info( + "Registering Command plugin '{}'({}) '{}'".format( + p_name, plugin_obj.version, plugin_obj.command_regex + ) + ) + pm.register(plugin_obj) + + plugin_dir = config["aprsd"].get("plugin_dir", None) + if plugin_dir: + LOG.info("Trying to load custom plugins from '{}'".format(plugin_dir)) + cpm = PluginManager() + plugins_list = cpm.load_plugins(plugin_dir) + LOG.info("Discovered {} modules to load".format(len(plugins_list))) + for o in plugins_list: + plugin_obj = None + if enabled_plugins: + if o["name"] in enabled_plugins: + plugin_obj = o["obj"] + else: + LOG.info( + "'{}' plugin not listed in config aprsd:enabled_plugins".format( + o["name"] + ) + ) + else: + # not setting enabled plugins means load all? + plugin_obj = o["obj"] + + if plugin_obj: + LOG.info( + "Registering Command plugin '{}'({}) '{}'".format( + o["name"], o["obj"].version, o["obj"].command_regex + ) + ) + pm.register(o["obj"]) + + else: + LOG.info("Skipping Custom Plugins.") + + LOG.info("Completed Plugin Loading.") + return pm + + +class PluginManager(object): + def __init__(self): + self.obj_list = [] + + def load_plugins(self, module_path): + dir_path = os.path.dirname(os.path.realpath(module_path)) + pattern = "*.py" + + self.obj_list = [] + + for path, subdirs, files in os.walk(dir_path): + for name in files: + if fnmatch.fnmatch(name, pattern): + found_module = imp.find_module(name[:-3], [path]) + module = imp.load_module( + name, found_module[0], found_module[1], found_module[2] + ) + for mem_name, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and inspect.getmodule(obj) is module + and self.is_plugin(obj) + ): + self.obj_list.append({"name": mem_name, "obj": obj()}) + + return self.obj_list + + def is_plugin(self, obj): + for c in inspect.getmro(obj): + if issubclass(c, APRSDPluginBase): + return True + + return False + + +class APRSDCommandSpec: + """A hook specification namespace.""" + + @hookspec + def run(self, fromcall, message, ack): + """My special little hook that you can customize.""" + pass + + +@six.add_metaclass(abc.ABCMeta) +class APRSDPluginBase(object): + @property + def command_regex(self): + raise NotImplementedError + + @property + def version(self): + raise NotImplementedError + + @hookimpl + def run(self, fromcall, message, ack): + if re.search(self.command_regex, message): + return self.command(fromcall, message, ack) + + @abc.abstractmethod + def command(self, fromcall, message, ack): + """This is the command that runs when the regex matches. + + To reply with a message over the air, return a string + to send. + """ + pass + + +class FortunePlugin(APRSDPluginBase): + """Fortune.""" + + version = "1.0" + command_regex = "^[fF]" + + def command(self, fromcall, message, ack): + LOG.info("FortunePlugin") + reply = None + try: + process = subprocess.Popen( + ["/usr/games/fortune", "-s", "-n 60"], stdout=subprocess.PIPE + ) + reply = process.communicate()[0] + # send_message(fromcall, reply.rstrip()) + reply = reply.decode(errors="ignore").rstrip() + except Exception as ex: + reply = "Fortune command failed '{}'".format(ex) + LOG.error(reply) + + return reply + + +class LocationPlugin(APRSDPluginBase): + """Location!""" + + version = "1.0" + command_regex = "^[lL]" + + def command(self, fromcall, message, ack): + LOG.info("Location Plugin") + # get last location of a callsign, get descriptive name from weather service + try: + a = re.search( + r"^.*\s+(.*)", message + ) # optional second argument is a callsign to search + if a is not None: + searchcall = a.group(1) + searchcall = searchcall.upper() + else: + searchcall = ( + fromcall # if no second argument, search for calling station + ) + url = ( + "http://api.aprs.fi/api/get?name=" + + searchcall + + "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json" + ) + response = requests.get(url) + # aprs_data = json.loads(response.read()) + aprs_data = json.loads(response.text) + lat = aprs_data["entries"][0]["lat"] + lon = aprs_data["entries"][0]["lng"] + try: # altitude not always provided + alt = aprs_data["entries"][0]["altitude"] + except Exception: + alt = 0 + altfeet = int(alt * 3.28084) + aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"] + aprs_lasttime_seconds = aprs_lasttime_seconds.encode( + "ascii", errors="ignore" + ) # unicode to ascii + delta_seconds = time.time() - int(aprs_lasttime_seconds) + delta_hours = delta_seconds / 60 / 60 + url2 = ( + "https://forecast.weather.gov/MapClick.php?lat=" + + str(lat) + + "&lon=" + + str(lon) + + "&FcstType=json" + ) + response2 = requests.get(url2) + wx_data = json.loads(response2.text) + + reply = "{}: {} {}' {},{} {}h ago".format( + searchcall, + wx_data["location"]["areaDescription"], + str(altfeet), + str(alt), + str(lon), + str("%.1f" % round(delta_hours, 1)), + ).rstrip() + except Exception as e: + LOG.debug("Locate failed with: " + "%s" % str(e)) + reply = "Unable to find station " + searchcall + ". Sending beacons?" + + return reply + + +class PingPlugin(APRSDPluginBase): + """Ping.""" + + version = "1.0" + command_regex = "^[pP]" + + def command(self, fromcall, message, ack): + LOG.info("PINGPlugin") + stm = time.localtime() + h = stm.tm_hour + m = stm.tm_min + s = stm.tm_sec + reply = ( + "Pong! " + str(h).zfill(2) + ":" + str(m).zfill(2) + ":" + str(s).zfill(2) + ) + return reply.rstrip() + + +class TimePlugin(APRSDPluginBase): + """Time command.""" + + version = "1.0" + command_regex = "^[tT]" + + def command(self, fromcall, message, ack): + LOG.info("TIME COMMAND") + stm = time.localtime() + h = stm.tm_hour + m = stm.tm_min + cur_time = fuzzy(h, m, 1) + reply = "{} ({}:{} PDT) ({})".format( + cur_time, str(h), str(m).rjust(2, "0"), message.rstrip() + ) + return reply + + +class WeatherPlugin(APRSDPluginBase): + """Weather Command""" + + version = "1.0" + command_regex = "^[wW]" + + def command(self, fromcall, message, ack): + LOG.info("Weather Plugin") + try: + url = ( + "http://api.aprs.fi/api/get?" + "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json" + "&name=%s" % fromcall + ) + response = requests.get(url) + # aprs_data = json.loads(response.read()) + aprs_data = json.loads(response.text) + lat = aprs_data["entries"][0]["lat"] + lon = aprs_data["entries"][0]["lng"] + url2 = ( + "https://forecast.weather.gov/MapClick.php?lat=%s" + "&lon=%s&FcstType=json" % (lat, lon) + ) + response2 = requests.get(url2) + # wx_data = json.loads(response2.read()) + wx_data = json.loads(response2.text) + reply = ( + "%sF(%sF/%sF) %s. %s, %s." + % ( + wx_data["currentobservation"]["Temp"], + wx_data["data"]["temperature"][0], + wx_data["data"]["temperature"][1], + wx_data["data"]["weather"][0], + wx_data["time"]["startPeriodName"][1], + wx_data["data"]["weather"][1], + ).rstrip() + ) + LOG.debug("reply: '{}' ".format(reply)) + except Exception as e: + LOG.debug("Weather failed with: " + "%s" % str(e)) + reply = "Unable to find you (send beacon?)" + + return reply diff --git a/aprsd/utils.py b/aprsd/utils.py index 347fdb8..5f6ce0c 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -7,6 +7,8 @@ import sys import click import yaml +from aprsd import plugin + # an example of what should be in the ~/.aprsd/config.yml DEFAULT_CONFIG_DICT = { "ham": {"callsign": "KFART"}, @@ -36,6 +38,10 @@ DEFAULT_CONFIG_DICT = { "port": 993, "use_ssl": True, }, + "aprsd": { + "plugin_dir": "~/.config/aprsd/plugins", + "enabled_plugins": plugin.CORE_PLUGINS, + }, } DEFAULT_CONFIG_FILE = "~/.config/aprsd/aprsd.yml" diff --git a/docker-compose.yml b/docker-compose.yml index 20f37be..87030e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: "3" services: aprsd: - image: hemna6969/aprsd:latest + image: hemna6969/aprsd:latest container_name: aprsd volumes: - /opt/docker/aprsd/config:/config diff --git a/examples/plugins/__init__.py b/examples/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/plugins/example_plugin.py b/examples/plugins/example_plugin.py new file mode 100644 index 0000000..83f5978 --- /dev/null +++ b/examples/plugins/example_plugin.py @@ -0,0 +1,18 @@ +import logging + +from aprsd import plugin + +LOG = logging.getLogger("APRSD") + + +class HelloPlugin(plugin.APRSDPluginBase): + """Hello World.""" + + version = "1.0" + # matches any string starting with h or H + command_regex = "^[hH]" + + def command(self, fromcall, message, ack): + LOG.info("HelloPlugin") + reply = "Hello '{}'".format(fromcall) + return reply diff --git a/requirements.txt b/requirements.txt index 2e9c7bd..14be40c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ click click-completion imapclient +pluggy pbr pyyaml six diff --git a/tox.ini b/tox.ini index 7deaf6a..12c04bf 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,7 @@ commands = # Use -Werror to treat warnings as errors. # {envpython} -bb -Werror -m pytest \ # --cov="{envsitepackagesdir}/aprsd" --cov-report=html --cov-report=term {posargs} - {envpython} -bb -Werror -m pytest {posargs} + {envpython} -bb -m pytest {posargs} [testenv:py27] setenv = VIRTUAL_ENV={envdir}