Added new time plugins

This patch adds 2 new time plugins to allow admins to use their
opencagedata APIkey or openweathermap API key to fetch the timezone
from the lat/lon GPS coordinates for the callsign requesting the time.

This will enable fetching the time local to the ham radio's last beacon,
and not time local to the aprsd server instance running.  If the
location is not found, then the timezone will default to UTC.

The 2 new plugins are
- aprsd.plugins.time.TimeOpenCageDataPlugin
   Fetches timezone from lat/lon using the opencagedata api that can be
   found here:  https://opencagedata.com/dashboard#api-keys

   This requires a new ~/.config/aprsd/aprsd.yml entry to specify the
   api key.
   opencagedata:
       apiKey: <the api key hash here>

- aprsd.plugins.time.TimeOWMPlugin
   Fetches the timezone from lat/lon using the openweathermap api
   that can be found here:  https://home.openweathermap.org/api_keys

   This requires a new ~/.config/aprsd/aprsd.yml entry to specify the
   api key.
   openweathermap:
       apiKey: <the api key hash here>
This commit is contained in:
Hemna 2021-01-20 10:19:49 -05:00
parent fdd5a6ba41
commit fc3a747aa4
10 changed files with 262 additions and 67 deletions

View File

@ -10,9 +10,9 @@ RUN apk add --update git wget py3-pip py3-virtualenv bash fortune
# Setup Timezone # Setup Timezone
ENV TZ=US/Eastern ENV TZ=US/Eastern
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone #RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN apt-get install -y tzdata #RUN apt-get install -y tzdata
RUN dpkg-reconfigure --frontend noninteractive tzdata #RUN dpkg-reconfigure --frontend noninteractive tzdata
RUN addgroup --gid 1000 $APRS_USER RUN addgroup --gid 1000 $APRS_USER

View File

@ -2,11 +2,11 @@ FROM alpine:latest as aprsd
# Dockerfile for building a container during aprsd development. # Dockerfile for building a container during aprsd development.
ENV VERSION=1.0.0 ENV VERSION=1.5.1
ENV APRS_USER=aprs ENV APRS_USER=aprs
ENV HOME=/home/aprs ENV HOME=/home/aprs
ENV APRSD=http://github.com/craigerl/aprsd.git ENV APRSD=http://github.com/craigerl/aprsd.git
ENV APRSD_BRANCH="v1.1.0" ENV APRSD_BRANCH="master"
ENV VIRTUAL_ENV=$HOME/.venv3 ENV VIRTUAL_ENV=$HOME/.venv3
ENV INSTALL=$HOME/install ENV INSTALL=$HOME/install

72
aprsd/plugin_utils.py Normal file
View File

@ -0,0 +1,72 @@
# Utilities for plugins to use
import json
import logging
import requests
LOG = logging.getLogger("APRSD")
def get_aprs_fi(api_key, callsign):
LOG.debug("Fetch aprs.fi location for '{}'".format(callsign))
try:
url = (
"http://api.aprs.fi/api/get?"
"&what=loc&apikey={}&format=json"
"&name={}".format(api_key, callsign)
)
response = requests.get(url)
except Exception:
raise Exception("Failed to get aprs.fi location")
else:
response.raise_for_status()
return json.loads(response.text)
def get_weather_gov_for_gps(lat, lon):
LOG.debug("Fetch station at {}, {}".format(lat, lon))
try:
url2 = (
"https://forecast.weather.gov/MapClick.php?lat=%s"
"&lon=%s&FcstType=json" % (lat, lon)
)
LOG.debug("Fetching weather '{}'".format(url2))
response = requests.get(url2)
except Exception as e:
LOG.error(e)
raise Exception("Failed to get weather")
else:
response.raise_for_status()
return json.loads(response.text)
def get_weather_gov_metar(station):
LOG.debug("Fetch metar for station '{}'".format(station))
try:
url = "https://api.weather.gov/stations/{}/observations/latest".format(
station,
)
response = requests.get(url)
except Exception:
raise Exception("Failed to fetch metar")
else:
response.raise_for_status()
return json.loads(response)
def fetch_openweathermap(api_key, lat, lon, exclude=None):
LOG.debug("Fetch openweathermap for {}, {}".format(lat, lon))
if not exclude:
exclude = "minutely,hourly,daily,alerts"
try:
url = (
"https://api.openweathermap.org/data/2.5/onecall?"
"lat={}&lon={}&appid={}&exclude={}".format(lat, lon, api_key, exclude)
)
response = requests.get(url)
except Exception as e:
LOG.error(e)
raise Exception("Failed to get weather")
else:
response.raise_for_status()
return json.loads(response.text)

View File

@ -1,10 +1,8 @@
import json
import logging import logging
import re import re
import time import time
from aprsd import plugin from aprsd import plugin, plugin_utils, utils
import requests
LOG = logging.getLogger("APRSD") LOG = logging.getLogger("APRSD")
@ -16,11 +14,15 @@ class LocationPlugin(plugin.APRSDPluginBase):
command_regex = "^[lL]" command_regex = "^[lL]"
command_name = "location" command_name = "location"
config_items = {"apikey": "aprs.fi api key here"}
def command(self, fromcall, message, ack): def command(self, fromcall, message, ack):
LOG.info("Location Plugin") LOG.info("Location Plugin")
# get last location of a callsign, get descriptive name from weather service # get last location of a callsign, get descriptive name from weather service
try:
utils.check_config_option(self.config, "aprs.fi", "apiKey")
except Exception as ex:
LOG.error("Failed to find config aprs.fi:apikey {}".format(ex))
return "No aprs.fi apikey found"
api_key = self.config["aprs.fi"]["apiKey"] api_key = self.config["aprs.fi"]["apiKey"]
try: try:
# optional second argument is a callsign to search # optional second argument is a callsign to search
@ -31,14 +33,13 @@ class LocationPlugin(plugin.APRSDPluginBase):
else: else:
# if no second argument, search for calling station # if no second argument, search for calling station
searchcall = fromcall searchcall = fromcall
url = (
"http://api.aprs.fi/api/get?name=" try:
+ searchcall aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
+ "&what=loc&apikey={}&format=json".format(api_key) except Exception as ex:
) LOG.error("Failed to fetch aprs.fi '{}'".format(ex))
response = requests.get(url) return "Failed to fetch aprs.fi location"
# aprs_data = json.loads(response.read())
aprs_data = json.loads(response.text)
LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data)) LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
lat = aprs_data["entries"][0]["lat"] lat = aprs_data["entries"][0]["lat"]
lon = aprs_data["entries"][0]["lng"] lon = aprs_data["entries"][0]["lng"]
@ -53,15 +54,12 @@ class LocationPlugin(plugin.APRSDPluginBase):
# ) # unicode to ascii # ) # unicode to ascii
delta_seconds = time.time() - int(aprs_lasttime_seconds) delta_seconds = time.time() - int(aprs_lasttime_seconds)
delta_hours = delta_seconds / 60 / 60 delta_hours = delta_seconds / 60 / 60
url2 = (
"https://forecast.weather.gov/MapClick.php?lat=" try:
+ str(lat) wx_data = plugin_utils.get_weather_gov_for_gps(lat, lon)
+ "&lon=" except Exception as ex:
+ str(lon) LOG.error("Couldn't fetch forecast.weather.gov '{}'".format(ex))
+ "&FcstType=json" wx_data["location"]["areaDescription"] = "Unkown Location"
)
response2 = requests.get(url2)
wx_data = json.loads(response2.text)
reply = "{}: {} {}' {},{} {}h ago".format( reply = "{}: {} {}' {},{} {}h ago".format(
searchcall, searchcall,

View File

@ -1,7 +1,8 @@
import logging import logging
import time import time
from aprsd import fuzzyclock, plugin from aprsd import fuzzyclock, plugin, plugin_utils, utils
from opencage.geocoder import OpenCageGeocode
import pytz import pytz
LOG = logging.getLogger("APRSD") LOG = logging.getLogger("APRSD")
@ -20,15 +21,7 @@ class TimePlugin(plugin.APRSDPluginBase):
def _get_utcnow(self): def _get_utcnow(self):
return pytz.datetime.datetime.utcnow() return pytz.datetime.datetime.utcnow()
def command(self, fromcall, message, ack): def build_date_str(self, localzone):
LOG.info("TIME COMMAND")
# So we can mock this in unit tests
localzone = self._get_local_tz()
# This is inefficient for now, but this enables
# us to add the ability to provide time in the TZ
# of the caller, if we can get the TZ from their callsign location
# This also accounts for running aprsd in different timezones
utcnow = self._get_utcnow() utcnow = self._get_utcnow()
gmt_t = pytz.utc.localize(utcnow) gmt_t = pytz.utc.localize(utcnow)
local_t = gmt_t.astimezone(localzone) local_t = gmt_t.astimezone(localzone)
@ -38,10 +31,94 @@ class TimePlugin(plugin.APRSDPluginBase):
local_min = local_t.strftime("%M") local_min = local_t.strftime("%M")
cur_time = fuzzyclock.fuzzy(int(local_hour), int(local_min), 1) cur_time = fuzzyclock.fuzzy(int(local_hour), int(local_min), 1)
reply = "{} ({}) ({})".format( reply = "{} ({})".format(
cur_time, cur_time,
local_short_str, local_short_str,
message.rstrip(),
) )
return reply return reply
def command(self, fromcall, message, ack):
LOG.info("TIME COMMAND")
# So we can mock this in unit tests
localzone = self._get_local_tz()
return self.build_date_str(localzone)
class TimeOpenCageDataPlugin(TimePlugin):
"""geocage based timezone fetching."""
version = "1.0"
command_regex = "^[tT]"
command_name = "Time"
def command(self, fromcall, message, ack):
api_key = self.config["aprs.fi"]["apiKey"]
try:
aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall)
except Exception as ex:
LOG.error("Failed to fetch aprs.fi data {}".format(ex))
return "Failed to fetch location"
# LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
lat = aprs_data["entries"][0]["lat"]
lon = aprs_data["entries"][0]["lng"]
try:
utils.check_config_option(self.config, "opencagedata", "apiKey")
except Exception as ex:
LOG.error("Failed to find config opencage:apiKey {}".format(ex))
return "No opencage apiKey found"
try:
opencage_key = self.config["opencagedata"]["apiKey"]
geocoder = OpenCageGeocode(opencage_key)
results = geocoder.reverse_geocode(lat, lon)
except Exception as ex:
LOG.error("Couldn't fetch opencagedata api '{}'".format(ex))
# Default to UTC instead
localzone = pytz.timezone("UTC")
else:
tzone = results[0]["annotations"]["timezone"]["name"]
localzone = pytz.timezone(tzone)
return self.build_date_str(localzone)
class TimeOWMPlugin(TimePlugin):
"""OpenWeatherMap based timezone fetching."""
version = "1.0"
command_regex = "^[tT]"
command_name = "Time"
def command(self, fromcall, message, ack):
api_key = self.config["aprs.fi"]["apiKey"]
try:
aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall)
except Exception as ex:
LOG.error("Failed to fetch aprs.fi data {}".format(ex))
return "Failed to fetch location"
# LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
lat = aprs_data["entries"][0]["lat"]
lon = aprs_data["entries"][0]["lng"]
try:
utils.check_config_option(self.config, "openweathermap", "apiKey")
except Exception as ex:
LOG.error("Failed to find config openweathermap:apiKey {}".format(ex))
return "No openweathermap apiKey found"
api_key = self.config["openweathermap"]["apiKey"]
try:
results = plugin_utils.fetch_openweathermap(api_key, lat, lon)
except Exception as ex:
LOG.error("Couldn't fetch openweathermap api '{}'".format(ex))
# default to UTC
localzone = pytz.timezone("UTC")
else:
tzone = results["timezone"]
localzone = pytz.timezone(tzone)
return self.build_date_str(localzone)

View File

@ -22,6 +22,8 @@ DEFAULT_CONFIG_DICT = {
"logfile": "/tmp/aprsd.log", "logfile": "/tmp/aprsd.log",
}, },
"aprs.fi": {"apiKey": "set me"}, "aprs.fi": {"apiKey": "set me"},
"openweathermap": {"apiKey": "set me"},
"opencagedata": {"apiKey": "set me"},
"shortcuts": { "shortcuts": {
"aa": "5551239999@vtext.com", "aa": "5551239999@vtext.com",
"cl": "craiglamparter@somedomain.org", "cl": "craiglamparter@somedomain.org",
@ -111,6 +113,24 @@ def add_config_comments(raw_yaml):
end_idx, end_idx,
) )
end_idx = end_substr(raw_yaml, "opencagedata:")
if end_idx != -1:
# lets insert a comment
raw_yaml = insert_str(
raw_yaml,
"\n # Get the apiKey from your opencagedata account here: https://opencagedata.com/dashboard#api-keys",
end_idx,
)
end_idx = end_substr(raw_yaml, "openweathermap:")
if end_idx != -1:
# lets insert a comment
raw_yaml = insert_str(
raw_yaml,
"\n # Get the apiKey from your openweathermap account here: https://home.openweathermap.org/api_keys",
end_idx,
)
return raw_yaml return raw_yaml
@ -154,6 +174,35 @@ def get_config(config_file):
sys.exit(-1) sys.exit(-1)
def check_config_option(config, section, name=None, default=None, default_fail=None):
if section in config:
if name and name not in config[section]:
if not default:
raise Exception(
"'{}' was not in '{}' section of config file".format(
name,
section,
),
)
else:
config[section][name] = default
else:
if (
default_fail
and name in config[section]
and config[section][name] == default_fail
):
# We have to fail and bail if the user hasn't edited
# this config option.
raise Exception(
"Config file needs to be edited from provided defaults.",
)
else:
raise Exception("'%s' section wasn't in config file" % section)
return config
# This method tries to parse the config yaml file # This method tries to parse the config yaml file
# and consume the settings. # and consume the settings.
# If the required params don't exist, # If the required params don't exist,
@ -167,30 +216,12 @@ def parse_config(config_file):
sys.exit(-1) sys.exit(-1)
def check_option(config, section, name=None, default=None, default_fail=None): def check_option(config, section, name=None, default=None, default_fail=None):
if section in config: try:
config = check_config_option(config, section, name, default, default_fail)
if name and name not in config[section]: except Exception as ex:
if not default: fail(repr(ex))
fail(
"'{}' was not in '{}' section of config file".format(
name,
section,
),
)
else:
config[section][name] = default
else:
if (
default_fail
and name in config[section]
and config[section][name] == default_fail
):
# We have to fail and bail if the user hasn't edited
# this config option.
fail("Config file needs to be edited from provided defaults.")
else: else:
fail("'%s' section wasn't in config file" % section) return config
return config
config = get_config(config_file) config = get_config(config_file)
check_option(config, "shortcuts") check_option(config, "shortcuts")

View File

@ -23,4 +23,4 @@ if [ ! -e "$APRSD_CONFIG" ]; then
echo "'$APRSD_CONFIG' File does not exist. Creating." echo "'$APRSD_CONFIG' File does not exist. Creating."
aprsd sample-config > $APRSD_CONFIG aprsd sample-config > $APRSD_CONFIG
fi fi
$VIRTUAL_ENV/bin/aprsd server -c $APRSD_CONFIG $VIRTUAL_ENV/bin/aprsd server -c $APRSD_CONFIG --loglevel DEBUG

View File

@ -11,3 +11,4 @@ aprslib
py3-validate-email py3-validate-email
pre-commit pre-commit
pytz pytz
opencage

View File

@ -2,14 +2,18 @@
# This file is autogenerated by pip-compile # This file is autogenerated by pip-compile
# To update, run: # To update, run:
# #
# pip-compile requirements.in # pip-compile
# #
appdirs==1.4.4 appdirs==1.4.4
# via virtualenv # via virtualenv
aprslib==0.6.47 aprslib==0.6.47
# via -r requirements.in # via -r requirements.in
backoff==1.10.0
# via opencage
certifi==2020.12.5 certifi==2020.12.5
# via requests # via requests
cffi==1.14.4
# via cryptography
cfgv==3.2.0 cfgv==3.2.0
# via pre-commit # via pre-commit
chardet==4.0.0 chardet==4.0.0
@ -20,6 +24,8 @@ click==7.1.2
# via # via
# -r requirements.in # -r requirements.in
# click-completion # click-completion
cryptography==3.3.1
# via pyopenssl
distlib==0.3.1 distlib==0.3.1
# via virtualenv # via virtualenv
dnspython==2.1.0 dnspython==2.1.0
@ -42,6 +48,8 @@ markupsafe==1.1.1
# via jinja2 # via jinja2
nodeenv==1.5.0 nodeenv==1.5.0
# via pre-commit # via pre-commit
opencage==1.2.2
# via -r requirements.in
pbr==5.5.1 pbr==5.5.1
# via -r requirements.in # via -r requirements.in
pluggy==0.13.1 pluggy==0.13.1
@ -50,6 +58,10 @@ pre-commit==2.9.3
# via -r requirements.in # via -r requirements.in
py3-validate-email==0.2.12 py3-validate-email==0.2.12
# via -r requirements.in # via -r requirements.in
pycparser==2.20
# via cffi
pyopenssl==20.0.1
# via opencage
pytz==2020.5 pytz==2020.5
# via -r requirements.in # via -r requirements.in
pyyaml==5.3.1 pyyaml==5.3.1
@ -57,14 +69,19 @@ pyyaml==5.3.1
# -r requirements.in # -r requirements.in
# pre-commit # pre-commit
requests==2.25.1 requests==2.25.1
# via -r requirements.in # via
# -r requirements.in
# opencage
shellingham==1.3.2 shellingham==1.3.2
# via click-completion # via click-completion
six==1.15.0 six==1.15.0
# via # via
# -r requirements.in # -r requirements.in
# click-completion # click-completion
# cryptography
# imapclient # imapclient
# opencage
# pyopenssl
# virtualenv # virtualenv
thesmuggler==1.0.1 thesmuggler==1.0.1
# via -r requirements.in # via -r requirements.in

View File

@ -97,10 +97,9 @@ class TestPlugin(unittest.TestCase):
message = "time" message = "time"
local_short_str = local_t.strftime("%H:%M %Z") local_short_str = local_t.strftime("%H:%M %Z")
expected = "{} ({}) ({})".format( expected = "{} ({})".format(
cur_time, cur_time,
local_short_str, local_short_str,
message.rstrip(),
) )
actual = time.run(fromcall, message, ack) actual = time.run(fromcall, message, ack)
self.assertEqual(expected, actual) self.assertEqual(expected, actual)