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}