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
ENV TZ=US/Eastern
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN apt-get install -y tzdata
RUN dpkg-reconfigure --frontend noninteractive tzdata
#RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
#RUN apt-get install -y tzdata
#RUN dpkg-reconfigure --frontend noninteractive tzdata
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.
ENV VERSION=1.0.0
ENV VERSION=1.5.1
ENV APRS_USER=aprs
ENV HOME=/home/aprs
ENV APRSD=http://github.com/craigerl/aprsd.git
ENV APRSD_BRANCH="v1.1.0"
ENV APRSD_BRANCH="master"
ENV VIRTUAL_ENV=$HOME/.venv3
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 re
import time
from aprsd import plugin
import requests
from aprsd import plugin, plugin_utils, utils
LOG = logging.getLogger("APRSD")
@ -16,11 +14,15 @@ class LocationPlugin(plugin.APRSDPluginBase):
command_regex = "^[lL]"
command_name = "location"
config_items = {"apikey": "aprs.fi api key here"}
def command(self, fromcall, message, ack):
LOG.info("Location Plugin")
# 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"]
try:
# optional second argument is a callsign to search
@ -31,14 +33,13 @@ class LocationPlugin(plugin.APRSDPluginBase):
else:
# if no second argument, search for calling station
searchcall = fromcall
url = (
"http://api.aprs.fi/api/get?name="
+ searchcall
+ "&what=loc&apikey={}&format=json".format(api_key)
)
response = requests.get(url)
# aprs_data = json.loads(response.read())
aprs_data = json.loads(response.text)
try:
aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
except Exception as ex:
LOG.error("Failed to fetch aprs.fi '{}'".format(ex))
return "Failed to fetch aprs.fi location"
LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
lat = aprs_data["entries"][0]["lat"]
lon = aprs_data["entries"][0]["lng"]
@ -53,15 +54,12 @@ class LocationPlugin(plugin.APRSDPluginBase):
# ) # 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)
try:
wx_data = plugin_utils.get_weather_gov_for_gps(lat, lon)
except Exception as ex:
LOG.error("Couldn't fetch forecast.weather.gov '{}'".format(ex))
wx_data["location"]["areaDescription"] = "Unkown Location"
reply = "{}: {} {}' {},{} {}h ago".format(
searchcall,

View File

@ -1,7 +1,8 @@
import logging
import time
from aprsd import fuzzyclock, plugin
from aprsd import fuzzyclock, plugin, plugin_utils, utils
from opencage.geocoder import OpenCageGeocode
import pytz
LOG = logging.getLogger("APRSD")
@ -20,15 +21,7 @@ class TimePlugin(plugin.APRSDPluginBase):
def _get_utcnow(self):
return pytz.datetime.datetime.utcnow()
def command(self, fromcall, message, ack):
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
def build_date_str(self, localzone):
utcnow = self._get_utcnow()
gmt_t = pytz.utc.localize(utcnow)
local_t = gmt_t.astimezone(localzone)
@ -38,10 +31,94 @@ class TimePlugin(plugin.APRSDPluginBase):
local_min = local_t.strftime("%M")
cur_time = fuzzyclock.fuzzy(int(local_hour), int(local_min), 1)
reply = "{} ({}) ({})".format(
reply = "{} ({})".format(
cur_time,
local_short_str,
message.rstrip(),
)
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",
},
"aprs.fi": {"apiKey": "set me"},
"openweathermap": {"apiKey": "set me"},
"opencagedata": {"apiKey": "set me"},
"shortcuts": {
"aa": "5551239999@vtext.com",
"cl": "craiglamparter@somedomain.org",
@ -111,6 +113,24 @@ def add_config_comments(raw_yaml):
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
@ -154,6 +174,35 @@ def get_config(config_file):
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
# and consume the settings.
# If the required params don't exist,
@ -167,30 +216,12 @@ def parse_config(config_file):
sys.exit(-1)
def check_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:
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.")
try:
config = check_config_option(config, section, name, default, default_fail)
except Exception as ex:
fail(repr(ex))
else:
fail("'%s' section wasn't in config file" % section)
return config
return config
config = get_config(config_file)
check_option(config, "shortcuts")

View File

@ -23,4 +23,4 @@ if [ ! -e "$APRSD_CONFIG" ]; then
echo "'$APRSD_CONFIG' File does not exist. Creating."
aprsd sample-config > $APRSD_CONFIG
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
pre-commit
pytz
opencage

View File

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

View File

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