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.
This commit is contained in:
Hemna 2020-12-12 15:53:06 -05:00
parent 8c9c12b3fc
commit d09a66006b
8 changed files with 368 additions and 155 deletions

View File

@ -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:

331
aprsd/plugin.py Normal file
View File

@ -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

View File

@ -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"

View File

@ -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

View File

View File

@ -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

View File

@ -1,6 +1,7 @@
click
click-completion
imapclient
pluggy
pbr
pyyaml
six

View File

@ -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}